summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/test
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/extensions/test')
-rw-r--r--toolkit/components/extensions/test/browser/.eslintrc.js11
-rw-r--r--toolkit/components/extensions/test/browser/browser-serviceworker.ini9
-rw-r--r--toolkit/components/extensions/test/browser/browser.ini56
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js285
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js126
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js138
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js91
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_eventpage_disableResetIdleForTest.js83
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_extension_page_tab_navigated.js226
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_management_themes.js177
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_process_crash_handling.js116
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_test_mock.js47
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js119
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js39
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js82
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js173
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js213
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js203
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js154
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js217
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js450
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js227
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js151
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js63
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js77
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js69
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js287
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js203
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js240
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_pbm.js439
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js64
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_reset.js112
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js187
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_separators.js76
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js275
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js126
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js39
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js51
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js54
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js70
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js48
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js183
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js107
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js63
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js109
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js105
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js144
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js94
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_webNavigation_eventpage.js72
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js48
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js133
-rw-r--r--toolkit/components/extensions/test/browser/data/test-download.txt1
-rw-r--r--toolkit/components/extensions/test/browser/data/test_downloads_referrer.html10
-rw-r--r--toolkit/components/extensions/test/browser/head.js126
-rw-r--r--toolkit/components/extensions/test/browser/head_serviceworker.js119
-rw-r--r--toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json11
-rw-r--r--toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js3
-rw-r--r--toolkit/components/extensions/test/marionette/manifest.ini2
-rw-r--r--toolkit/components/extensions/test/marionette/service_worker_testutils.py48
-rw-r--r--toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py56
-rw-r--r--toolkit/components/extensions/test/marionette/test_temporary_extension_serviceworkers_not_persisted.py54
-rw-r--r--toolkit/components/extensions/test/mochitest/.eslintrc.js12
-rw-r--r--toolkit/components/extensions/test/mochitest/chrome.ini38
-rw-r--r--toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js65
-rw-r--r--toolkit/components/extensions/test/mochitest/chrome_head.js1
-rw-r--r--toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html12
-rw-r--r--toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html7
-rw-r--r--toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html9
-rw-r--r--toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_contains_iframe.html13
-rw-r--r--toolkit/components/extensions/test/mochitest/file_contains_img.html12
-rw-r--r--toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html11
-rw-r--r--toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_green.html3
-rw-r--r--toolkit/components/extensions/test/mochitest/file_green_blue.html16
-rw-r--r--toolkit/components/extensions/test/mochitest/file_image_bad.pngbin0 -> 5401 bytes
-rw-r--r--toolkit/components/extensions/test/mochitest/file_image_good.pngbin0 -> 580 bytes
-rw-r--r--toolkit/components/extensions/test/mochitest/file_image_great.pngbin0 -> 580 bytes
-rw-r--r--toolkit/components/extensions/test/mochitest/file_image_redirect.pngbin0 -> 5401 bytes
-rw-r--r--toolkit/components/extensions/test/mochitest/file_indexedDB.html28
-rw-r--r--toolkit/components/extensions/test/mochitest/file_mixed.html13
-rw-r--r--toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html30
-rw-r--r--toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html9
-rw-r--r--toolkit/components/extensions/test/mochitest/file_remote_frame.html20
-rw-r--r--toolkit/components/extensions/test/mochitest/file_sample.html13
-rw-r--r--toolkit/components/extensions/test/mochitest/file_sample.txt1
-rw-r--r--toolkit/components/extensions/test/mochitest/file_sample.txt^headers^1
-rw-r--r--toolkit/components/extensions/test/mochitest/file_script_bad.js3
-rw-r--r--toolkit/components/extensions/test/mochitest/file_script_good.js12
-rw-r--r--toolkit/components/extensions/test/mochitest/file_script_redirect.js3
-rw-r--r--toolkit/components/extensions/test/mochitest/file_script_xhr.js9
-rw-r--r--toolkit/components/extensions/test/mochitest/file_serviceWorker.html16
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_iframe_worker.html26
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html23
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_sharedworker.js11
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_webrequest_worker.html28
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_worker.js8
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_xhr.html19
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html19
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html23
-rw-r--r--toolkit/components/extensions/test/mochitest/file_slowed_document.sjs49
-rw-r--r--toolkit/components/extensions/test/mochitest/file_streamfilter.txt1
-rw-r--r--toolkit/components/extensions/test/mochitest/file_style_bad.css3
-rw-r--r--toolkit/components/extensions/test/mochitest/file_style_good.css3
-rw-r--r--toolkit/components/extensions/test/mochitest/file_style_redirect.css3
-rw-r--r--toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html11
-rw-r--r--toolkit/components/extensions/test/mochitest/file_third_party.html21
-rw-r--r--toolkit/components/extensions/test/mochitest/file_to_drawWindow.html9
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html9
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html8
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^1
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html12
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html12
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html12
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html8
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html7
-rw-r--r--toolkit/components/extensions/test/mochitest/file_with_about_blank.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_with_images.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_with_subframes_and_embed.html21
-rw-r--r--toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html6
-rw-r--r--toolkit/components/extensions/test/mochitest/head.js117
-rw-r--r--toolkit/components/extensions/test/mochitest/head_cookies.js287
-rw-r--r--toolkit/components/extensions/test/mochitest/head_notifications.js171
-rw-r--r--toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js45
-rw-r--r--toolkit/components/extensions/test/mochitest/head_webrequest.js481
-rw-r--r--toolkit/components/extensions/test/mochitest/hsts.sjs10
-rw-r--r--toolkit/components/extensions/test/mochitest/mochitest-common.ini350
-rw-r--r--toolkit/components/extensions/test/mochitest/mochitest-remote.ini11
-rw-r--r--toolkit/components/extensions/test/mochitest/mochitest-serviceworker.ini28
-rw-r--r--toolkit/components/extensions/test/mochitest/mochitest.ini13
-rw-r--r--toolkit/components/extensions/test/mochitest/mochitest_console.js54
-rw-r--r--toolkit/components/extensions/test/mochitest/oauth.html26
-rw-r--r--toolkit/components/extensions/test/mochitest/redirect_auto.sjs24
-rw-r--r--toolkit/components/extensions/test/mochitest/redirection.sjs6
-rw-r--r--toolkit/components/extensions/test/mochitest/return_headers.sjs19
-rw-r--r--toolkit/components/extensions/test/mochitest/serviceWorker.js0
-rw-r--r--toolkit/components/extensions/test/mochitest/slow_response.sjs60
-rw-r--r--toolkit/components/extensions/test/mochitest/test_check_startupcache.html61
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html104
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html68
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html80
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html114
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html257
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html116
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html172
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_svg_context_fill.html204
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html98
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html81
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html94
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html89
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html193
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html58
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_action.html51
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_activityLog.html390
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_all_apis.js245
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html401
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html42
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_background_page.html84
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_background_page_dpi.html46
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup.html183
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_incognito_window.html151
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_windowId.html162
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_without_pref.html58
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html159
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html323
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html69
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html141
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html65
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html64
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_clipboard.html210
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html262
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html116
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html711
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html117
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html134
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html77
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html109
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_getFrameId.html189
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html101
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html59
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies.html367
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html98
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html72
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html316
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html107
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html115
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html89
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_dnr_other_extensions.html113
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html137
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html137
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html94
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html94
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html91
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_extension_iframe_messaging.html124
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html110
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_generate.html48
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_geolocation.html86
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_identity.html390
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_idle.html68
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html49
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html62
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html168
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_notifications.html340
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_optional_permissions.html98
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html580
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html92
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html130
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html83
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html102
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_iframe.html136
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html126
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html77
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_script_filenames.html62
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_scripting_contentScripts.html1532
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript.html1479
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_activeTab.html144
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_injectImmediately.html215
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_scripting_insertCSS.html395
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_scripting_permissions.html149
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_scripting_removeCSS.html135
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html100
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html45
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html115
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html78
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html202
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html277
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html130
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html108
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html91
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html76
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html340
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html324
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_tabs_create_cookieStoreId.html210
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_tabs_executeScript_good.html162
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html752
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html102
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html152
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_test.html341
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html138
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html170
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html567
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html610
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html313
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html105
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html131
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html181
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html120
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html445
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html59
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html226
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html213
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_getSecurityInfo.html98
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html252
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html75
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html83
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html139
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html265
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_worker.html192
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html104
-rw-r--r--toolkit/components/extensions/test/mochitest/test_startup_canary.html76
-rw-r--r--toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html32
-rw-r--r--toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html22
-rw-r--r--toolkit/components/extensions/test/mochitest/test_verify_sw_mode.html24
-rw-r--r--toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js9
-rw-r--r--toolkit/components/extensions/test/mochitest/webrequest_test.sys.mjs16
-rw-r--r--toolkit/components/extensions/test/mochitest/webrequest_worker.js3
-rw-r--r--toolkit/components/extensions/test/xpcshell/.eslintrc.js13
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.sys.mjs62
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.sys.mjs20
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/dummy_page.html7
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt0
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file download.txt1
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html25
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html19
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js2
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html19
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js2
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_content_script_errors.html7
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_csp.html14
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^1
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html9
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_document_open.html21
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_document_write.html36
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_download.html12
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_download.txt1
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_iframe.html9
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_image_bad.pngbin0 -> 5401 bytes
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_image_good.pngbin0 -> 580 bytes
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_image_redirect.pngbin0 -> 5401 bytes
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html34
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html61
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html13
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_sample.html12
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html13
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_script.html14
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_script_bad.js12
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_script_good.js12
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js3
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js9
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html13
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_style_bad.css3
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_style_good.css3
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css3
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css1
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html3
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html19
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_toplevel.html12
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_with_iframe.html11
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html10
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/lorem.html.gzbin0 -> 392 bytes
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/pixel_green.gifbin0 -> 35 bytes
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/pixel_red.gifbin0 -> 35 bytes
-rw-r--r--toolkit/components/extensions/test/xpcshell/head.js354
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_dnr.js203
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_e10s.js8
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_legacy_ep.js13
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_native_messaging.js152
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_remote.js7
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_schemas.js129
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_service_worker.js158
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_storage.js1400
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_sync.js66
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_telemetry.js435
-rw-r--r--toolkit/components/extensions/test/xpcshell/native_messaging.ini19
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js141
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js86
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_MatchPattern.js602
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains.js217
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js274
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js323
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js620
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js20
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js303
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_csp_validator.js322
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js80
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js78
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js160
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js129
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_alarms.js346
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js34
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js50
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js56
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_api_events_listener_calls_exceptions.js369
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js75
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_asyncAPICall_isHandlingUserInput.js149
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js35
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js187
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js23
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js24
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js24
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js44
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js88
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_service_worker.js321
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js46
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js98
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js98
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_type_module.js133
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js41
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js54
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js528
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js36
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_browser_style_deprecation.js335
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js48
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js456
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js192
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_cache_api.js303
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js202
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js53
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_clear_cached_resources.js417
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js808
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js362
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js270
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js78
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js65
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js79
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js128
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js359
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js168
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js177
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js433
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js48
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_dynamic_registration.js205
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js150
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js98
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_importmap.js124
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js43
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js102
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js277
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js71
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_change.js104
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js87
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js149
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js61
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js101
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js1383
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js91
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js75
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js62
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js59
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contexts.js201
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js277
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js588
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js567
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_cookies_errors.js168
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js334
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_cookies_onChanged.js142
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_cookies_partitionKey.js895
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js114
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_cors_mozextension.js220
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_csp_frame_ancestors.js221
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_csp_upgrade_requests.js74
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js312
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js1231
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_api.js383
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_download.js193
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_dynamic_rules.js1245
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_modifyHeaders.js1072
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_private_browsing.js130
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js723
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js590
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js549
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_session_rules.js1111
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_startup_cache.js651
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_static_rules.js1849
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_system_restrictions.js283
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_tabIds.js249
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js1504
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js1159
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_webrequest.js296
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_without_webrequest.js877
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dns.js176
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads.js38
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js469
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js219
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js685
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_eventpage.js162
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js1169
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_partitionKey.js199
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js306
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js682
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js257
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_error_location.js48
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_eventpage_idle.js574
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_eventpage_settings.js166
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js99
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_experiments.js377
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_extension.js74
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js877
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js1085
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js146
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_extension_page_navigated.js341
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js46
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js87
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_file_access.js193
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js205
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js68
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_geturl.js64
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_i18n.js571
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js194
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_idle.js361
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_incognito.js127
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js147
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js150
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js108
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_l10n.js165
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js50
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_management.js339
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js146
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_manifest.js460
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js114
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js45
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js12
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js12
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js35
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js277
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js1111
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js130
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js85
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js209
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js105
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js41
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js30
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js86
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js845
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js240
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_permissions.js1035
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js464
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js268
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js157
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js1636
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_privacy.js979
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js180
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_privacy_nonPersistentCookies.js54
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js163
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js116
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js614
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_containerIsolation.js59
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js302
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js104
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js660
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js53
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js144
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_redirects.js660
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js26
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBackgroundPage.js172
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js26
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js36
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js46
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js84
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js599
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js69
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js170
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js462
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js118
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js66
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js67
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js93
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js131
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js233
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js42
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_sandboxed_resource.js55
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schema.js80
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas.js2118
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js160
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js352
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js173
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js171
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js161
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js507
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js242
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_versioned.js714
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_script_filenames.js366
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts.js412
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_css.js331
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_file.js77
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_scripting_mv2.js23
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_scripting_persistAcrossSessions.js760
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_scripting_startupCache.js167
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_scripting_updateContentScripts.js114
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_secfetch.js352
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js59
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_shared_array_buffer.js104
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js40
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js43
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_simple.js208
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_startupData.js55
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js178
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_startup_cache_telemetry.js162
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js70
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js64
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js39
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js31
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js31
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js790
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js83
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js212
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js44
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js80
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js107
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_session.js97
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js35
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js2318
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js122
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js245
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js362
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js97
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js917
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js55
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js60
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_theme_experiments.js109
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js20
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js60
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js211
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js230
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js729
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js1108
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_userScripts_register.js142
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_wasm.js135
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js425
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js311
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js68
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_containerIsolation.js59
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js44
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_eventPage_StreamFilter.js350
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js607
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js87
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js35
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js57
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js99
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js88
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js545
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js153
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirectProperty.js64
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js129
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js47
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js57
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js764
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_restrictedHeaders.js252
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js308
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js751
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js76
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js49
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js289
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js45
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js41
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js95
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js144
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js55
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webSocket.js162
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js148
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources_matches.js546
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js72
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_xhr_cors.js223
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_extension_permissions_migrate_kvstore_path.js234
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js112
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js171
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_locale_converter.js146
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_locale_data.js221
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_native_manifests.js541
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_proxy_failover.js323
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js95
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js462
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_proxy_listener.js298
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js43
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_resistfingerprinting_exempt.js40
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_site_permissions.js385
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js79
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js102
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js182
-rw-r--r--toolkit/components/extensions/test/xpcshell/webidl-api/.eslintrc.js9
-rw-r--r--toolkit/components/extensions/test/xpcshell/webidl-api/head_webidl_api.js306
-rw-r--r--toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js486
-rw-r--r--toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js575
-rw-r--r--toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js443
-rw-r--r--toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js202
-rw-r--r--toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js99
-rw-r--r--toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js220
-rw-r--r--toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.ini32
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini21
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell-common.ini436
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell-content.ini70
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini30
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini21
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini42
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell-serviceworker.ini37
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell.ini101
649 files changed, 125255 insertions, 0 deletions
diff --git a/toolkit/components/extensions/test/browser/.eslintrc.js b/toolkit/components/extensions/test/browser/.eslintrc.js
new file mode 100644
index 0000000000..ef228570e3
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/.eslintrc.js
@@ -0,0 +1,11 @@
+"use strict";
+
+module.exports = {
+ env: {
+ webextensions: true,
+ },
+
+ rules: {
+ "no-shadow": "off",
+ },
+};
diff --git a/toolkit/components/extensions/test/browser/browser-serviceworker.ini b/toolkit/components/extensions/test/browser/browser-serviceworker.ini
new file mode 100644
index 0000000000..58e9082f7b
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser-serviceworker.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+support-files =
+ head_serviceworker.js
+ data/**
+
+prefs =
+ extensions.backgroundServiceWorker.enabled=true
+
+[browser_ext_background_serviceworker.js]
diff --git a/toolkit/components/extensions/test/browser/browser.ini b/toolkit/components/extensions/test/browser/browser.ini
new file mode 100644
index 0000000000..a26e73ee37
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser.ini
@@ -0,0 +1,56 @@
+[DEFAULT]
+support-files =
+ head.js
+ data/**
+
+[browser_ext_background_serviceworker_pref_disabled.js]
+[browser_ext_downloads_filters.js]
+[browser_ext_downloads_referrer.js]
+https_first_disabled = true
+[browser_ext_eventpage_disableResetIdleForTest.js]
+[browser_ext_extension_page_tab_navigated.js]
+[browser_ext_management_themes.js]
+skip-if = verify
+[browser_ext_process_crash_handling.js]
+skip-if = !crashreporter
+[browser_ext_test_mock.js]
+[browser_ext_themes_additional_backgrounds_alignment.js]
+[browser_ext_themes_alpha_accentcolor.js]
+[browser_ext_themes_arrowpanels.js]
+[browser_ext_themes_autocomplete_popup.js]
+[browser_ext_themes_chromeparity.js]
+[browser_ext_themes_dynamic_getCurrent.js]
+[browser_ext_themes_dynamic_onUpdated.js]
+[browser_ext_themes_dynamic_updates.js]
+[browser_ext_themes_experiment.js]
+[browser_ext_themes_findbar.js]
+[browser_ext_themes_getCurrent_differentExt.js]
+[browser_ext_themes_highlight.js]
+[browser_ext_themes_incognito.js]
+[browser_ext_themes_lwtsupport.js]
+[browser_ext_themes_multiple_backgrounds.js]
+[browser_ext_themes_ntp_colors.js]
+[browser_ext_themes_ntp_colors_perwindow.js]
+[browser_ext_themes_pbm.js]
+[browser_ext_themes_persistence.js]
+[browser_ext_themes_reset.js]
+[browser_ext_themes_sanitization.js]
+[browser_ext_themes_separators.js]
+[browser_ext_themes_sidebars.js]
+[browser_ext_themes_static_onUpdated.js]
+[browser_ext_themes_tab_line.js]
+[browser_ext_themes_tab_loading.js]
+[browser_ext_themes_tab_selected.js]
+[browser_ext_themes_tab_text.js]
+[browser_ext_themes_theme_transition.js]
+[browser_ext_themes_toolbar_fields.js]
+[browser_ext_themes_toolbar_fields_focus.js]
+[browser_ext_themes_toolbarbutton_colors.js]
+[browser_ext_themes_toolbarbutton_icons.js]
+[browser_ext_themes_toolbars.js]
+[browser_ext_themes_warnings.js]
+[browser_ext_thumbnails_bg_extension.js]
+support-files = !/toolkit/components/thumbnails/test/head.js
+[browser_ext_webNavigation_eventpage.js]
+[browser_ext_webRequest_redirect_mozextension.js]
+[browser_ext_windows_popup_title.js]
diff --git a/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js
new file mode 100644
index 0000000000..153818f4de
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js
@@ -0,0 +1,285 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* globals getBackgroundServiceWorkerRegistration, waitForServiceWorkerTerminated */
+
+Services.scriptloader.loadSubScript(
+ new URL("head_serviceworker.js", gTestPath).href,
+ this
+);
+
+add_task(assert_background_serviceworker_pref_enabled);
+
+add_task(async function test_serviceWorker_register_guarded_by_pref() {
+ // Test with backgroundServiceWorkeEnable set to true and the
+ // extensions.serviceWorkerRegist.allowed pref set to false.
+ // NOTE: the scenario with backgroundServiceWorkeEnable set to false
+ // is part of "browser_ext_background_serviceworker_pref_disabled.js".
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.serviceWorkerRegister.allowed", false]],
+ });
+
+ let extensionData = {
+ files: {
+ "page.html": "<!DOCTYPE html><script src='page.js'></script>",
+ "page.js": async function () {
+ browser.test.assertEq(
+ undefined,
+ navigator.serviceWorker,
+ "navigator.serviceWorker should be undefined"
+ );
+ browser.test.sendMessage("test-serviceWorker-register-disallowed");
+ },
+ "sw.js": "",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ // Verify that an extension page can't register a moz-extension url
+ // as a service worker.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: `moz-extension://${extension.uuid}/page.html`,
+ },
+ async () => {
+ await extension.awaitMessage("test-serviceWorker-register-disallowed");
+ }
+ );
+
+ await extension.unload();
+
+ await SpecialPowers.popPrefEnv();
+
+ // Test again with the pref set to true.
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.serviceWorkerRegister.allowed", true]],
+ });
+
+ extension = ExtensionTestUtils.loadExtension({
+ files: {
+ ...extensionData.files,
+ "page.js": async function () {
+ try {
+ await navigator.serviceWorker.register("sw.js");
+ } catch (err) {
+ browser.test.fail(
+ `Unexpected error on registering a service worker: ${err}`
+ );
+ throw err;
+ } finally {
+ browser.test.sendMessage("test-serviceworker-register-allowed");
+ }
+ },
+ },
+ });
+ await extension.startup();
+
+ // Verify that an extension page can register a moz-extension url
+ // as a service worker if enabled by the related pref.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: `moz-extension://${extension.uuid}/page.html`,
+ },
+ async () => {
+ await extension.awaitMessage("test-serviceworker-register-allowed");
+ }
+ );
+
+ await extension.unload();
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_cache_api_allowed() {
+ // Verify that Cache API support for moz-extension url availability is
+ // conditioned only by the extensions.backgroundServiceWorker.enabled pref.
+ // NOTE: the scenario with backgroundServiceWorkeEnable set to false
+ // is part of "browser_ext_background_serviceworker_pref_disabled.js".
+ const extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ try {
+ let cache = await window.caches.open("test-cache-api");
+ browser.test.assertTrue(
+ await window.caches.has("test-cache-api"),
+ "CacheStorage.has should resolve to true"
+ );
+
+ // Test that adding and requesting cached moz-extension urls
+ // works as well.
+ let url = browser.runtime.getURL("file.txt");
+ await cache.add(url);
+ const content = await cache.match(url).then(res => res.text());
+ browser.test.assertEq(
+ "file content",
+ content,
+ "Got the expected content from the cached moz-extension url"
+ );
+
+ // Test that deleting the cache storage works as expected.
+ browser.test.assertTrue(
+ await window.caches.delete("test-cache-api"),
+ "Cache deleted successfully"
+ );
+ browser.test.assertTrue(
+ !(await window.caches.has("test-cache-api")),
+ "CacheStorage.has should resolve to false"
+ );
+ } catch (err) {
+ browser.test.fail(`Unexpected error on using Cache API: ${err}`);
+ throw err;
+ } finally {
+ browser.test.sendMessage("test-cache-api-allowed");
+ }
+ },
+ files: {
+ "file.txt": "file content",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("test-cache-api-allowed");
+ await extension.unload();
+});
+
+function createTestSWScript({ postMessageReply }) {
+ return `
+ self.onmessage = msg => {
+ dump("Background ServiceWorker - onmessage handler\\n");
+ msg.ports[0].postMessage("${postMessageReply}");
+ dump("Background ServiceWorker - postMessage\\n");
+ };
+ dump("Background ServiceWorker - executed\\n");
+ `;
+}
+
+async function testServiceWorker({ extension, expectMessageReply }) {
+ // Verify that the WebExtensions framework has successfully registered the
+ // background service worker declared in the extension manifest.
+ const swRegInfo = getBackgroundServiceWorkerRegistration(extension);
+
+ // Activate the background service worker by exchanging a message
+ // with it.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: `moz-extension://${extension.uuid}/page.html`,
+ },
+ async browser => {
+ let msgFromV1 = await SpecialPowers.spawn(
+ browser,
+ [swRegInfo.scriptURL],
+ async url => {
+ const { active } = await content.navigator.serviceWorker.ready;
+ const { port1, port2 } = new content.MessageChannel();
+
+ return new Promise(resolve => {
+ port1.onmessage = msg => resolve(msg.data);
+ active.postMessage("test", [port2]);
+ });
+ }
+ );
+
+ Assert.deepEqual(
+ msgFromV1,
+ expectMessageReply,
+ "Got the expected reply from the extension service worker"
+ );
+ }
+ );
+}
+
+function loadTestExtension({ version }) {
+ const postMessageReply = `reply:sw-v${version}`;
+
+ return ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ version,
+ background: {
+ service_worker: "sw.js",
+ },
+ browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } },
+ },
+ files: {
+ "page.html": "<!DOCTYPE html><body></body>",
+ "sw.js": createTestSWScript({ postMessageReply }),
+ },
+ });
+}
+
+async function assertWorkerIsRunningInExtensionProcess(extension) {
+ // Activate the background service worker by exchanging a message
+ // with it.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: `moz-extension://${extension.uuid}/page.html`,
+ },
+ async browser => {
+ const workerScriptURL = `moz-extension://${extension.uuid}/sw.js`;
+ const workerDebuggerURLs = await SpecialPowers.spawn(
+ browser,
+ [workerScriptURL],
+ async url => {
+ await content.navigator.serviceWorker.ready;
+ const wdm = Cc[
+ "@mozilla.org/dom/workers/workerdebuggermanager;1"
+ ].getService(Ci.nsIWorkerDebuggerManager);
+
+ return Array.from(wdm.getWorkerDebuggerEnumerator())
+ .map(wd => {
+ return wd.url;
+ })
+ .filter(swURL => swURL == url);
+ }
+ );
+
+ Assert.deepEqual(
+ workerDebuggerURLs,
+ [workerScriptURL],
+ "The worker should be running in the extension child process"
+ );
+ }
+ );
+}
+
+add_task(async function test_background_serviceworker_with_no_ext_apis() {
+ const extensionV1 = loadTestExtension({ version: "1" });
+ await extensionV1.startup();
+
+ const swRegInfo = getBackgroundServiceWorkerRegistration(extensionV1);
+ const { uuid } = extensionV1;
+
+ await assertWorkerIsRunningInExtensionProcess(extensionV1);
+ await testServiceWorker({
+ extension: extensionV1,
+ expectMessageReply: "reply:sw-v1",
+ });
+
+ // Load a new version of the same addon and verify that the
+ // expected worker script is being executed.
+ const extensionV2 = loadTestExtension({ version: "2" });
+ await extensionV2.startup();
+ is(extensionV2.uuid, uuid, "The extension uuid did not change as expected");
+
+ await testServiceWorker({
+ extension: extensionV2,
+ expectMessageReply: "reply:sw-v2",
+ });
+
+ await Promise.all([
+ extensionV2.unload(),
+ // test extension v1 wrapper has to be unloaded explicitly, otherwise
+ // will be detected as a failure by the test harness.
+ extensionV1.unload(),
+ ]);
+ await waitForServiceWorkerTerminated(swRegInfo);
+ await waitForServiceWorkerRegistrationsRemoved(extensionV2);
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js
new file mode 100644
index 0000000000..0194cd237f
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js
@@ -0,0 +1,126 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function assert_background_serviceworker_pref_disabled() {
+ is(
+ WebExtensionPolicy.backgroundServiceWorkerEnabled,
+ false,
+ "Expect extensions.backgroundServiceWorker.enabled to be false"
+ );
+});
+
+add_task(async function test_background_serviceworker_disallowed() {
+ const id = "test-disallowed-worker@test";
+
+ const extensionData = {
+ manifest: {
+ background: {
+ service_worker: "sw.js",
+ },
+ applicantions: { gecko: { id } },
+ useAddonManager: "temporary",
+ },
+ };
+
+ SimpleTest.waitForExplicitFinish();
+ let waitForConsole = new Promise(resolve => {
+ SimpleTest.monitorConsole(resolve, [
+ {
+ message:
+ /Reading manifest: Error processing background: background.service_worker is currently disabled/,
+ },
+ ]);
+ });
+
+ const extension = ExtensionTestUtils.loadExtension(extensionData);
+ await Assert.rejects(
+ extension.startup(),
+ /startup failed/,
+ "Startup failed with background.service_worker while disabled by pref"
+ );
+
+ SimpleTest.endMonitorConsole();
+ await waitForConsole;
+});
+
+add_task(async function test_serviceWorker_register_disallowed() {
+ // Verify that setting extensions.serviceWorkerRegist.allowed pref to false
+ // doesn't allow serviceWorker.register if backgroundServiceWorkeEnable is
+ // set to false
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.serviceWorkerRegister.allowed", true]],
+ });
+
+ let extensionData = {
+ files: {
+ "page.html": "<!DOCTYPE html><script src='page.js'></script>",
+ "page.js": async function () {
+ try {
+ await navigator.serviceWorker.register("sw.js");
+ browser.test.fail(
+ `An extension page should not be able to register a serviceworker successfully`
+ );
+ } catch (err) {
+ browser.test.assertEq(
+ String(err),
+ "SecurityError: The operation is insecure.",
+ "Got the expected error on registering a service worker from a script"
+ );
+ }
+ browser.test.sendMessage("test-serviceWorker-register-disallowed");
+ },
+ "sw.js": "",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ // Verify that an extension page can't register a moz-extension url
+ // as a service worker.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: `moz-extension://${extension.uuid}/page.html`,
+ },
+ async () => {
+ await extension.awaitMessage("test-serviceWorker-register-disallowed");
+ }
+ );
+
+ await extension.unload();
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_cache_api_disallowed() {
+ // Verify that Cache API support for moz-extension url availability is also
+ // conditioned by the extensions.backgroundServiceWorker.enabled pref.
+ const extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ try {
+ const cache = await window.caches.open("test-cache-api");
+ let url = browser.runtime.getURL("file.txt");
+ await browser.test.assertRejects(
+ cache.add(url),
+ new RegExp(`Cache.add: Request URL ${url} must be either`),
+ "Got the expected rejections on calling cache.add with a moz-extension:// url"
+ );
+ } catch (err) {
+ browser.test.fail(`Unexpected error on using Cache API: ${err}`);
+ throw err;
+ } finally {
+ browser.test.sendMessage("test-cache-api-disallowed");
+ }
+ },
+ files: {
+ "file.txt": "file content",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("test-cache-api-disallowed");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js b/toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js
new file mode 100644
index 0000000000..18d68d2061
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js
@@ -0,0 +1,138 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+async function testAppliedFilters(ext, expectedFilter, expectedFilterCount) {
+ let tempDir = FileUtils.getDir(
+ "TmpD",
+ [`testDownloadDir-${Math.random()}`],
+ true
+ );
+
+ let filterCount = 0;
+
+ let MockFilePicker = SpecialPowers.MockFilePicker;
+ MockFilePicker.init(window);
+ MockFilePicker.displayDirectory = tempDir;
+ MockFilePicker.returnValue = MockFilePicker.returnCancel;
+ MockFilePicker.appendFiltersCallback = function (fp, val) {
+ const hexstr = "0x" + ("000" + val.toString(16)).substr(-3);
+ filterCount++;
+ if (filterCount < expectedFilterCount) {
+ is(val, expectedFilter, "Got expected filter: " + hexstr);
+ } else if (filterCount == expectedFilterCount) {
+ is(val, MockFilePicker.filterAll, "Got all files filter: " + hexstr);
+ } else {
+ is(val, null, "Got unexpected filter: " + hexstr);
+ }
+ };
+ MockFilePicker.showCallback = function (fp) {
+ const filename = fp.defaultString;
+ info("MockFilePicker - save as: " + filename);
+ };
+
+ let manifest = {
+ description: ext,
+ permissions: ["downloads"],
+ };
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: manifest,
+
+ background: async function () {
+ let ext = chrome.runtime.getManifest().description;
+ await browser.test.assertRejects(
+ browser.downloads.download({
+ url: "http://any-origin/any-path/any-resource",
+ filename: "any-file" + ext,
+ saveAs: true,
+ }),
+ "Download canceled by the user",
+ "expected request to be canceled"
+ );
+ browser.test.sendMessage("canceled");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("canceled");
+ await extension.unload();
+
+ is(
+ filterCount,
+ expectedFilterCount,
+ "Got correct number of filters: " + filterCount
+ );
+
+ MockFilePicker.cleanup();
+
+ tempDir.remove(true);
+}
+
+// Missing extension
+add_task(async function testDownload_missing_All() {
+ await testAppliedFilters("", null, 1);
+});
+
+// Unrecognized extension
+add_task(async function testDownload_unrecognized_All() {
+ await testAppliedFilters(".xxx", null, 1);
+});
+
+// Recognized extensions
+add_task(async function testDownload_html_HTML() {
+ await testAppliedFilters(".html", Ci.nsIFilePicker.filterHTML, 2);
+});
+
+add_task(async function testDownload_xhtml_HTML() {
+ await testAppliedFilters(".xhtml", Ci.nsIFilePicker.filterHTML, 2);
+});
+
+add_task(async function testDownload_txt_Text() {
+ await testAppliedFilters(".txt", Ci.nsIFilePicker.filterText, 2);
+});
+
+add_task(async function testDownload_text_Text() {
+ await testAppliedFilters(".text", Ci.nsIFilePicker.filterText, 2);
+});
+
+add_task(async function testDownload_jpe_Images() {
+ await testAppliedFilters(".jpe", Ci.nsIFilePicker.filterImages, 2);
+});
+
+add_task(async function testDownload_tif_Images() {
+ await testAppliedFilters(".tif", Ci.nsIFilePicker.filterImages, 2);
+});
+
+add_task(async function testDownload_webp_Images() {
+ await testAppliedFilters(".webp", Ci.nsIFilePicker.filterImages, 2);
+});
+
+add_task(async function testDownload_xml_XML() {
+ await testAppliedFilters(".xml", Ci.nsIFilePicker.filterXML, 2);
+});
+
+add_task(async function testDownload_aac_Audio() {
+ await testAppliedFilters(".aac", Ci.nsIFilePicker.filterAudio, 2);
+});
+
+add_task(async function testDownload_mp3_Audio() {
+ await testAppliedFilters(".mp3", Ci.nsIFilePicker.filterAudio, 2);
+});
+
+add_task(async function testDownload_wma_Audio() {
+ await testAppliedFilters(".wma", Ci.nsIFilePicker.filterAudio, 2);
+});
+
+add_task(async function testDownload_avi_Video() {
+ await testAppliedFilters(".avi", Ci.nsIFilePicker.filterVideo, 2);
+});
+
+add_task(async function testDownload_mp4_Video() {
+ await testAppliedFilters(".mp4", Ci.nsIFilePicker.filterVideo, 2);
+});
+
+add_task(async function testDownload_xvid_Video() {
+ await testAppliedFilters(".xvid", Ci.nsIFilePicker.filterVideo, 2);
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js b/toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js
new file mode 100644
index 0000000000..9690df6376
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js
@@ -0,0 +1,91 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+const URL_PATH = "browser/toolkit/components/extensions/test/browser/data";
+const TEST_URL = `http://example.com/${URL_PATH}/test_downloads_referrer.html`;
+const DOWNLOAD_URL = `http://example.com/${URL_PATH}/test-download.txt`;
+
+async function triggerSaveAs({ selector }) {
+ const contextMenu = window.document.getElementById("contentAreaContextMenu");
+ const popupshown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ selector,
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await popupshown;
+ let saveLinkCommand = window.document.getElementById("context-savelink");
+ contextMenu.activateItem(saveLinkCommand);
+}
+
+add_setup(() => {
+ const tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tempDir.append("test-download-dir");
+ if (!tempDir.exists()) {
+ tempDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+
+ let MockFilePicker = SpecialPowers.MockFilePicker;
+ MockFilePicker.init(window);
+ registerCleanupFunction(function () {
+ MockFilePicker.cleanup();
+
+ if (tempDir.exists()) {
+ tempDir.remove(true);
+ }
+ });
+
+ MockFilePicker.displayDirectory = tempDir;
+ MockFilePicker.showCallback = function (fp) {
+ info("MockFilePicker: shown");
+ const filename = fp.defaultString;
+ info("MockFilePicker: save as " + filename);
+ const destFile = tempDir.clone();
+ destFile.append(filename);
+ MockFilePicker.setFiles([destFile]);
+ info("MockFilePicker: showCallback done");
+ };
+});
+
+add_task(async function test_download_item_referrer_info() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["downloads"],
+ },
+ async background() {
+ browser.downloads.onCreated.addListener(async downloadInfo => {
+ browser.test.sendMessage("download-on-created", downloadInfo);
+ });
+ browser.downloads.onChanged.addListener(async downloadInfo => {
+ // Wait download to be completed.
+ if (downloadInfo.state?.current !== "complete") {
+ return;
+ }
+ browser.test.sendMessage("download-completed");
+ });
+
+ // Call an API method implemented in the parent process to make sure
+ // registering the downloas.onCreated event listener has been completed.
+ await browser.runtime.getBrowserInfo();
+
+ browser.test.sendMessage("bg-page:ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bg-page:ready");
+
+ await BrowserTestUtils.withNewTab({ gBrowser, url: TEST_URL }, async () => {
+ await triggerSaveAs({ selector: "a.test-link" });
+ const downloadInfo = await extension.awaitMessage("download-on-created");
+ is(downloadInfo.url, DOWNLOAD_URL, "Got the expected download url");
+ is(downloadInfo.referrer, TEST_URL, "Got the expected referrer");
+ });
+
+ // Wait for the download to have been completed and removed.
+ await extension.awaitMessage("download-completed");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_eventpage_disableResetIdleForTest.js b/toolkit/components/extensions/test/browser/browser_ext_eventpage_disableResetIdleForTest.js
new file mode 100644
index 0000000000..8178411e80
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_eventpage_disableResetIdleForTest.js
@@ -0,0 +1,83 @@
+"use strict";
+
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+
+const { AppUiTestDelegate } = ChromeUtils.importESModule(
+ "resource://testing-common/AppUiTestDelegate.sys.mjs"
+);
+
+// Ignore error "Actor 'Conduits' destroyed before query 'RunListener' was resolved"
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Actor 'Conduits' destroyed before query 'RunListener'/
+);
+
+async function run_test_disableResetIdleForTest(options) {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ action: {},
+ },
+ background() {
+ browser.action.onClicked.addListener(async () => {
+ browser.test.notifyPass("action-clicked");
+ // Deliberately keep this listener active to simulate a still active listener
+ // callback, while calling extension.terminateBackground().
+ await new Promise(() => {});
+ });
+
+ browser.test.sendMessage("background-ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+ // After startup, the listener should be persistent but not primed.
+ assertPersistentListeners(extension, "browserAction", "onClicked", {
+ primed: false,
+ });
+
+ // Terminating the background should prime the persistent listener.
+ await extension.terminateBackground();
+ assertPersistentListeners(extension, "browserAction", "onClicked", {
+ primed: true,
+ });
+
+ // Wake up the background, and verify the listener is no longer primed.
+ await AppUiTestDelegate.clickBrowserAction(window, extension.id);
+ await extension.awaitFinish("action-clicked");
+ await AppUiTestDelegate.closeBrowserAction(window, extension.id);
+ await extension.awaitMessage("background-ready");
+ assertPersistentListeners(extension, "browserAction", "onClicked", {
+ primed: false,
+ });
+
+ // Terminate the background again, while the onClicked listener is still
+ // being executed.
+ // With options.disableResetIdleForTest = true, the termination should NOT
+ // be skipped and the listener should become primed again.
+ // With options.disableResetIdleForTest = false or unset, the termination
+ // should be skipped and the listener should not become primed.
+ await extension.terminateBackground(options);
+ assertPersistentListeners(extension, "browserAction", "onClicked", {
+ primed: !!options?.disableResetIdleForTest,
+ });
+
+ await extension.unload();
+}
+
+// Verify default behaviour when terminating a background while a
+// listener is still running: The background should not be terminated
+// and the listener should not become primed. Not specifyiny a value
+// for disableResetIdleForTest defauls to disableResetIdleForTest:false.
+add_task(async function test_disableResetIdleForTest_default() {
+ await run_test_disableResetIdleForTest();
+});
+
+// Verify that disableResetIdleForTest:true is honoured and terminating
+// a background while a listener is still running is enforced: The
+// background should be terminated and the listener should become primed.
+add_task(async function test_disableResetIdleForTest_true() {
+ await run_test_disableResetIdleForTest({ disableResetIdleForTest: true });
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_extension_page_tab_navigated.js b/toolkit/components/extensions/test/browser/browser_ext_extension_page_tab_navigated.js
new file mode 100644
index 0000000000..b2fb9484c2
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_extension_page_tab_navigated.js
@@ -0,0 +1,226 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+// The test tasks in this test file tends to trigger an intermittent
+// exception raised from JSActor::AfterDestroy, because of a race between
+// when the WebExtensions API event is being emitted from the parent process
+// and the navigation triggered on the test extension pages.
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Actor 'Conduits' destroyed before query 'RunListener' was resolved/
+);
+
+AddonTestUtils.initMochitest(this);
+
+const server = AddonTestUtils.createHttpServer({
+ hosts: ["example.com", "anotherwebpage.org"],
+});
+
+server.registerPathHandler("/", (request, response) => {
+ response.write(`<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <title>test webpage</title>
+ </head>
+ </html>
+ `);
+});
+
+function createTestExtPage({ script }) {
+ return `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="${script}"></script>
+ </head>
+ </html>
+ `;
+}
+
+function createTestExtPageScript(name) {
+ return `(${function (pageName) {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.log(
+ `Extension page "${pageName}" got a webRequest event: ${details.url}`
+ );
+ browser.test.sendMessage(`event-received:${pageName}`);
+ },
+ { types: ["main_frame"], urls: ["http://example.com/*"] }
+ );
+ /* eslint-disable mozilla/balanced-listeners */
+ window.addEventListener("pageshow", () => {
+ browser.test.log(`Extension page "${pageName}" got a pageshow event`);
+ browser.test.sendMessage(`pageshow:${pageName}`);
+ });
+ window.addEventListener("pagehide", () => {
+ browser.test.log(`Extension page "${pageName}" got a pagehide event`);
+ browser.test.sendMessage(`pagehide:${pageName}`);
+ });
+ /* eslint-enable mozilla/balanced-listeners */
+ }})("${name}");`;
+}
+
+// Triggers a WebRequest listener registered by the test extensions by
+// opening a tab on the given web page URL and then closing it after
+// it did load.
+async function triggerWebRequestListener(webPageURL, pause) {
+ let webPageTab = await BrowserTestUtils.openNewForegroundTab(
+ {
+ gBrowser,
+ url: webPageURL,
+ },
+ true /* waitForLoad */,
+ true /* waitForStop */
+ );
+ BrowserTestUtils.removeTab(webPageTab);
+}
+
+// The following tests tasks are testing the expected behaviors related to same-process and cross-process
+// navigations for an extension page, similarly to test_ext_extension_page_navigated.js, but unlike its
+// xpcshell counterpart this tests are only testing that after navigating back to an extension page
+// previously stored in the BFCache the WebExtensions events subscribed are being received as expected.
+
+add_task(async function test_extension_page_sameprocess_navigation() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "http://example.com/*"],
+ },
+ files: {
+ "extpage1.html": createTestExtPage({ script: "extpage1.js" }),
+ "extpage1.js": createTestExtPageScript("extpage1"),
+ "extpage2.html": createTestExtPage({ script: "extpage2.js" }),
+ "extpage2.js": createTestExtPageScript("extpage2"),
+ },
+ });
+
+ await extension.startup();
+
+ const policy = WebExtensionPolicy.getByID(extension.id);
+
+ const extPageURL1 = policy.extension.baseURI.resolve("extpage1.html");
+ const extPageURL2 = policy.extension.baseURI.resolve("extpage2.html");
+
+ info("Opening extension page in a new tab");
+ const extPageTab = await BrowserTestUtils.addTab(gBrowser, extPageURL1);
+ let browser = gBrowser.getBrowserForTab(extPageTab);
+ info("Wait for the extension page to be loaded");
+ await extension.awaitMessage("pageshow:extpage1");
+
+ await triggerWebRequestListener("http://example.com");
+ await extension.awaitMessage("event-received:extpage1");
+ ok(true, "extpage1 got a webRequest event as expected");
+
+ info("Load a second extension page in the same tab");
+ BrowserTestUtils.loadURIString(browser, extPageURL2);
+
+ info("Wait extpage1 to receive a pagehide event");
+ await extension.awaitMessage("pagehide:extpage1");
+ info("Wait extpage2 to receive a pageshow event");
+ await extension.awaitMessage("pageshow:extpage2");
+
+ info(
+ "Trigger a web request event and expect extpage2 to be the only one receiving it"
+ );
+ await triggerWebRequestListener("http://example.com");
+ await extension.awaitMessage("event-received:extpage2");
+ ok(true, "extpage2 got a webRequest event as expected");
+
+ info(
+ "Navigating back to extpage1 and expect extpage2 to be the only one receiving the webRequest event"
+ );
+
+ browser.goBack();
+ info("Wait for extpage1 to receive a pageshow event");
+ await extension.awaitMessage("pageshow:extpage1");
+ info("Wait for extpage2 to receive a pagehide event");
+ await extension.awaitMessage("pagehide:extpage2");
+
+ // We only expect extpage1 to be able to receive API events.
+ await triggerWebRequestListener("http://example.com");
+ await extension.awaitMessage("event-received:extpage1");
+ ok(true, "extpage1 got a webRequest event as expected");
+
+ BrowserTestUtils.removeTab(extPageTab);
+ await extension.awaitMessage("pagehide:extpage1");
+
+ await extension.unload();
+});
+
+add_task(async function test_extension_page_context_navigated_to_web_page() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "http://example.com/*"],
+ },
+ files: {
+ "extpage.html": createTestExtPage({ script: "extpage.js" }),
+ "extpage.js": createTestExtPageScript("extpage"),
+ },
+ });
+
+ await extension.startup();
+
+ const policy = WebExtensionPolicy.getByID(extension.id);
+
+ const extPageURL = policy.extension.baseURI.resolve("extpage.html");
+ // NOTE: this test will navigate the extension page to a webpage url that
+ // isn't matching the match pattern the test extension is going to use
+ // in its webRequest event listener, otherwise the extension page being
+ // navigated will be intermittently able to receive an event before it
+ // is navigated to the webpage url (and moved into the BFCache or destroyed)
+ // and trigger an intermittent failure of this test.
+ const webPageURL = "http://anotherwebpage.org/";
+ const triggerWebRequestURL = "http://example.com/";
+
+ info("Opening extension page in a new tab");
+ const extPageTab1 = await BrowserTestUtils.addTab(gBrowser, extPageURL);
+ let browserForTab1 = gBrowser.getBrowserForTab(extPageTab1);
+ info("Wait for the extension page to be loaded");
+ await extension.awaitMessage("pageshow:extpage");
+
+ info("Navigate the tab from the extension page to a web page");
+ let promiseLoaded = BrowserTestUtils.browserLoaded(
+ browserForTab1,
+ false,
+ webPageURL
+ );
+ BrowserTestUtils.loadURIString(browserForTab1, webPageURL);
+ info("Wait the tab to have loaded the new webpage url");
+ await promiseLoaded;
+ info("Wait the extension page to receive a pagehide event");
+ await extension.awaitMessage("pagehide:extpage");
+
+ // Trigger a webRequest listener, the extension page is expected to
+ // not be active, if that isn't the case a test message will be queued
+ // and will trigger an explicit test failure.
+ await triggerWebRequestListener(triggerWebRequestURL);
+
+ info("Navigate back to the extension page");
+ browserForTab1.goBack();
+ info("Wait for extension page to receive a pageshow event");
+ await extension.awaitMessage("pageshow:extpage");
+
+ await triggerWebRequestListener(triggerWebRequestURL);
+ await extension.awaitMessage("event-received:extpage");
+ ok(
+ true,
+ "extpage got a webRequest event as expected after being restored from BFCache"
+ );
+
+ info("Cleanup and exit test");
+ BrowserTestUtils.removeTab(extPageTab1);
+
+ info("Wait the extension page to receive a pagehide event");
+ await extension.awaitMessage("pagehide:extpage");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_management_themes.js b/toolkit/components/extensions/test/browser/browser_ext_management_themes.js
new file mode 100644
index 0000000000..d3cfa536b8
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_management_themes.js
@@ -0,0 +1,177 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+const { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+
+add_task(async function test_management_themes() {
+ await BuiltInThemes.ensureBuiltInThemes();
+
+ const TEST_ID = "test_management_themes@tests.mozilla.com";
+
+ let theme = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Simple theme test",
+ version: "1.0",
+ description: "test theme",
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ useAddonManager: "temporary",
+ });
+
+ async function background(TEST_ID) {
+ browser.management.onInstalled.addListener(info => {
+ if (info.name == TEST_ID) {
+ return;
+ }
+ browser.test.log(`${info.name} was installed`);
+ browser.test.assertEq(info.type, "theme", "addon is theme");
+ browser.test.sendMessage("onInstalled", info.name);
+ });
+ browser.management.onDisabled.addListener(info => {
+ browser.test.log(`${info.name} was disabled`);
+ browser.test.assertEq(info.type, "theme", "addon is theme");
+ browser.test.sendMessage("onDisabled", info.name);
+ });
+ browser.management.onEnabled.addListener(info => {
+ browser.test.log(`${info.name} was enabled`);
+ browser.test.assertEq(info.type, "theme", "addon is theme");
+ browser.test.sendMessage("onEnabled", info.name);
+ });
+ browser.management.onUninstalled.addListener(info => {
+ browser.test.log(`${info.name} was uninstalled`);
+ browser.test.assertEq(info.type, "theme", "addon is theme");
+ browser.test.sendMessage("onUninstalled", info.name);
+ });
+
+ async function getAddon(type) {
+ let addons = await browser.management.getAll();
+ let themes = addons.filter(addon => addon.type === "theme");
+ const STANDARD_BUILTIN_THEME_IDS = [
+ "default-theme@mozilla.org",
+ "firefox-compact-light@mozilla.org",
+ "firefox-compact-dark@mozilla.org",
+ "firefox-alpenglow@mozilla.org",
+ ];
+ // Check that management.getAll returns the built-in themes and our test
+ // extension.
+ for (let id of [...STANDARD_BUILTIN_THEME_IDS, TEST_ID]) {
+ let builtInExtension = addons.find(addon => {
+ return addon.id === id;
+ });
+ browser.test.assertTrue(
+ !!builtInExtension,
+ `The extension with id ${id} was returned by getAll.`
+ );
+ }
+ let found;
+ for (let addon of themes) {
+ browser.test.assertEq(addon.type, "theme", "addon is theme");
+ if (type == "theme" && addon.id.includes("temporary-addon")) {
+ found = addon;
+ } else if (type == "enabled" && addon.enabled) {
+ found = addon;
+ }
+ }
+ return found;
+ }
+
+ browser.test.onMessage.addListener(async msg => {
+ let theme = await getAddon("theme");
+ browser.test.assertEq(
+ theme.description,
+ "test theme",
+ "description is correct"
+ );
+ browser.test.assertTrue(theme.enabled, "theme is enabled");
+ await browser.management.setEnabled(theme.id, false);
+
+ theme = await getAddon("theme");
+
+ browser.test.assertTrue(!theme.enabled, "theme is disabled");
+ let addon = getAddon("enabled");
+ browser.test.assertTrue(addon, "another theme was enabled");
+
+ await browser.management.setEnabled(theme.id, true);
+ theme = await getAddon("theme");
+ addon = await getAddon("enabled");
+ browser.test.assertEq(theme.id, addon.id, "theme is enabled");
+
+ browser.test.sendMessage("done");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: TEST_ID,
+ },
+ },
+ name: TEST_ID,
+ permissions: ["management"],
+ },
+ background: `(${background})("${TEST_ID}")`,
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ await theme.startup();
+ is(
+ await extension.awaitMessage("onInstalled"),
+ "Simple theme test",
+ "webextension theme installed"
+ );
+ is(
+ await extension.awaitMessage("onDisabled"),
+ "System theme — auto",
+ "default disabled"
+ );
+
+ extension.sendMessage("test");
+ is(
+ await extension.awaitMessage("onEnabled"),
+ "System theme — auto",
+ "default enabled"
+ );
+ is(
+ await extension.awaitMessage("onDisabled"),
+ "Simple theme test",
+ "addon disabled"
+ );
+ is(
+ await extension.awaitMessage("onEnabled"),
+ "Simple theme test",
+ "addon enabled"
+ );
+ is(
+ await extension.awaitMessage("onDisabled"),
+ "System theme — auto",
+ "default disabled"
+ );
+ await extension.awaitMessage("done");
+
+ await Promise.all([theme.unload(), extension.awaitMessage("onUninstalled")]);
+
+ is(
+ await extension.awaitMessage("onEnabled"),
+ "System theme — auto",
+ "default enabled"
+ );
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_process_crash_handling.js b/toolkit/components/extensions/test/browser/browser_ext_process_crash_handling.js
new file mode 100644
index 0000000000..09ded0bcc1
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_process_crash_handling.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const { ExtensionProcessCrashObserver, Management } =
+ ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs");
+
+AddonTestUtils.initMochitest(this);
+
+add_task(async function test_ExtensionProcessCrashObserver() {
+ let mv2Extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ manifest_version: 2,
+ },
+ background() {
+ browser.test.sendMessage("background_running");
+ },
+ });
+
+ await mv2Extension.startup();
+ await mv2Extension.awaitMessage("background_running");
+
+ let { currentProcessChildID, lastCrashedProcessChildID } =
+ ExtensionProcessCrashObserver;
+
+ Assert.notEqual(
+ currentProcessChildID,
+ undefined,
+ "Expect ExtensionProcessCrashObserver.currentProcessChildID to be set"
+ );
+
+ Assert.equal(
+ ChromeUtils.getAllDOMProcesses().find(
+ pp => pp.childID == currentProcessChildID
+ )?.remoteType,
+ "extension",
+ "Expect a child process with remoteType extension to be found for the process childID set"
+ );
+
+ Assert.notEqual(
+ lastCrashedProcessChildID,
+ currentProcessChildID,
+ "Expect lastCrashedProcessChildID to not be set to the same value that currentProcessChildID is set"
+ );
+
+ let mv3Extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ manifest_version: 3,
+ },
+ background() {
+ browser.test.sendMessage("background_running");
+ },
+ });
+
+ const waitForExtensionBrowserInserted = () =>
+ new Promise(resolve => {
+ const listener = (_eventName, browser) => {
+ if (!browser.getAttribute("webextension-view-type") === "background") {
+ return;
+ }
+ Management.off("extension-browser-inserted", listener);
+ resolve(browser);
+ };
+ Management.on("extension-browser-inserted", listener);
+ });
+
+ const waitForExtensionProcessCrashNotified = () =>
+ new Promise(resolve => {
+ Management.once("extension-process-crash", (_evt, data) => resolve(data));
+ });
+
+ const promiseBackgroundBrowser = waitForExtensionBrowserInserted();
+
+ const promiseExtensionProcessCrashNotified =
+ waitForExtensionProcessCrashNotified();
+
+ await mv3Extension.startup();
+ await mv3Extension.awaitMessage("background_running");
+ const bgPageBrowser = await promiseBackgroundBrowser;
+
+ info("Force extension process crash");
+ // NOTE: shouldShowTabCrashPage option needs to be set to false
+ // to make sure crashFrame method resolves without waiting for a
+ // tab crash page (which is not going to be shown for a background
+ // page browser element).
+ await BrowserTestUtils.crashFrame(
+ bgPageBrowser,
+ /* shouldShowTabCrashPage */ false
+ );
+
+ info("Verify ExtensionProcessCrashObserver after extension process crash");
+ Assert.equal(
+ ExtensionProcessCrashObserver.lastCrashedProcessChildID,
+ currentProcessChildID,
+ "Expect ExtensionProcessCrashObserver.lastCrashedProcessChildID to be set to the expected childID"
+ );
+
+ info("Expect the same childID to have been notified as a Management event");
+ Assert.deepEqual(
+ await promiseExtensionProcessCrashNotified,
+ { childID: currentProcessChildID },
+ "Got the expected childID notified as part of the extension-process-crash Management event"
+ );
+
+ info("Wait for mv3 extension shutdown");
+ await mv3Extension.unload();
+ info("Wait for mv2 extension shutdown");
+ await mv2Extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_test_mock.js b/toolkit/components/extensions/test/browser/browser_ext_test_mock.js
new file mode 100644
index 0000000000..de496b7631
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_test_mock.js
@@ -0,0 +1,47 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// This test verifies that the extension mocks behave consistently, regardless
+// of test type (xpcshell vs browser test).
+// See also toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js
+
+// Check the state of the extension object. This should be consistent between
+// browser tests and xpcshell tests.
+async function checkExtensionStartupAndUnload(ext) {
+ await ext.startup();
+ Assert.ok(ext.id, "Extension ID should be available");
+ Assert.ok(ext.uuid, "Extension UUID should be available");
+ await ext.unload();
+ // Once set nothing clears the UUID.
+ Assert.ok(ext.uuid, "Extension UUID exists after unload");
+}
+
+add_task(async function test_MockExtension() {
+ // When "useAddonManager" is set, a MockExtension is created in the main
+ // process, which does not necessarily behave identically to an Extension.
+ let ext = ExtensionTestUtils.loadExtension({
+ // xpcshell/test_ext_test_mock.js tests "temporary", so here we use
+ // "permanent" to have even more test coverage.
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@permanent-mock-extension" } },
+ },
+ });
+
+ Assert.ok(!ext.id, "Extension ID is initially unavailable");
+ Assert.ok(!ext.uuid, "Extension UUID is initially unavailable");
+ await checkExtensionStartupAndUnload(ext);
+ Assert.ok(ext.id, "Extension ID exists after unload");
+});
+
+add_task(async function test_generated_Extension() {
+ let ext = ExtensionTestUtils.loadExtension({
+ manifest: {},
+ });
+
+ Assert.ok(!ext.id, "Extension ID is initially unavailable");
+ Assert.ok(!ext.uuid, "Extension UUID is initially unavailable");
+ await checkExtensionStartupAndUnload(ext);
+ Assert.ok(ext.id, "Extension ID exists after unload");
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js b/toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js
new file mode 100644
index 0000000000..cc814b4ae8
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js
@@ -0,0 +1,119 @@
+"use strict";
+
+// Case 1 - When there is a theme_frame image and additional_backgrounds_alignment is not specified.
+// So background-position should default to "right top"
+add_task(async function test_default_additional_backgrounds_alignment() {
+ const RIGHT_TOP = "100% 0%";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ additional_backgrounds: ["image1.png", "image1.png"],
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ let toolbox = document.querySelector("#navigator-toolbox");
+ let toolboxCS = window.getComputedStyle(toolbox);
+ if (backgroundColorSetOnRoot()) {
+ let docEl = document.documentElement;
+ let rootCS = window.getComputedStyle(docEl);
+
+ Assert.equal(
+ rootCS.getPropertyValue("background-position"),
+ `${RIGHT_TOP}, ${RIGHT_TOP}`,
+ "root contains theme_frame and lwt-background-alignment properties"
+ );
+ Assert.equal(
+ toolboxCS.getPropertyValue("background-position"),
+ RIGHT_TOP,
+ toolbox.id + " contains lwt-background-alignment properties"
+ );
+ } else {
+ /**
+ * We expect duplicate background-position values because we apply `right top`
+ * once for theme_frame, and again as the default value of
+ * --lwt-background-alignment.
+ */
+ Assert.equal(
+ toolboxCS.getPropertyValue("background-position"),
+ `${RIGHT_TOP}, ${RIGHT_TOP}`,
+ toolbox.id +
+ " contains theme_frame and default lwt-background-alignment properties"
+ );
+ }
+
+ await extension.unload();
+});
+
+// Case 2 - When there is a theme_frame image and additional_backgrounds_alignment is specified.
+add_task(async function test_additional_backgrounds_alignment() {
+ const LEFT_BOTTOM = "0% 100%";
+ const CENTER_CENTER = "50% 50%";
+ const RIGHT_TOP = "100% 0%";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ additional_backgrounds: ["image1.png", "image1.png", "image1.png"],
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ },
+ properties: {
+ additional_backgrounds_alignment: [
+ "left bottom",
+ "center center",
+ "right top",
+ ],
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ let toolbox = document.querySelector("#navigator-toolbox");
+ let toolboxCS = window.getComputedStyle(toolbox);
+ if (backgroundColorSetOnRoot()) {
+ let docEl = document.documentElement;
+ let rootCS = window.getComputedStyle(docEl);
+ Assert.equal(
+ rootCS.getPropertyValue("background-position"),
+ `${RIGHT_TOP}, ${LEFT_BOTTOM}, ${CENTER_CENTER}, ${RIGHT_TOP}`,
+ "root contains theme_frame and additional_backgrounds alignment properties"
+ );
+ Assert.equal(
+ toolboxCS.getPropertyValue("background-position"),
+ LEFT_BOTTOM + ", " + CENTER_CENTER + ", " + RIGHT_TOP,
+ toolbox.id + " contains additional_backgrounds alignment properties"
+ );
+ } else {
+ Assert.equal(
+ toolboxCS.getPropertyValue("background-position"),
+ RIGHT_TOP + ", " + LEFT_BOTTOM + ", " + CENTER_CENTER + ", " + RIGHT_TOP,
+ toolbox.id +
+ " contains theme_frame and additional_backgrounds alignment properties"
+ );
+ }
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js b/toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js
new file mode 100644
index 0000000000..2799d37551
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js
@@ -0,0 +1,39 @@
+"use strict";
+
+add_task(async function test_alpha_frame_color() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: "rgba(230, 128, 0, 0.1)",
+ tab_background_text: TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ let computedStyle;
+ if (backgroundColorSetOnRoot()) {
+ let docEl = window.document.documentElement;
+ computedStyle = window.getComputedStyle(docEl);
+ } else {
+ let toolbox = document.querySelector("#navigator-toolbox");
+ computedStyle = window.getComputedStyle(toolbox);
+ }
+
+ Assert.equal(
+ computedStyle.backgroundColor,
+ "rgb(230, 128, 0)",
+ "Window background color should be opaque"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js b/toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js
new file mode 100644
index 0000000000..6665fb3092
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js
@@ -0,0 +1,82 @@
+"use strict";
+
+function openIdentityPopup() {
+ let promise = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+ gIdentityHandler._identityIconBox.click();
+ return promise;
+}
+
+function closeIdentityPopup() {
+ let promise = BrowserTestUtils.waitForEvent(
+ gIdentityHandler._identityPopup,
+ "popuphidden"
+ );
+ gIdentityHandler._identityPopup.hidePopup();
+ return promise;
+}
+
+// This test checks applied WebExtension themes that attempt to change
+// popup properties
+
+add_task(async function test_popup_styling(browser, accDoc) {
+ const POPUP_BACKGROUND_COLOR = "#FF0000";
+ const POPUP_TEXT_COLOR = "#008000";
+ const POPUP_BORDER_COLOR = "#0000FF";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ popup: POPUP_BACKGROUND_COLOR,
+ popup_text: POPUP_TEXT_COLOR,
+ popup_border: POPUP_BORDER_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "https://example.com" },
+ async function (browser) {
+ await extension.startup();
+
+ // Open the information arrow panel
+ await openIdentityPopup();
+
+ let arrowContent = gIdentityHandler._identityPopup.panelContent;
+ let arrowContentComputedStyle = window.getComputedStyle(arrowContent);
+ // Ensure popup background color was set properly
+ Assert.equal(
+ arrowContentComputedStyle.getPropertyValue("background-color"),
+ `rgb(${hexToRGB(POPUP_BACKGROUND_COLOR).join(", ")})`,
+ "Popup background color should have been themed"
+ );
+
+ // Ensure popup text color was set properly
+ Assert.equal(
+ arrowContentComputedStyle.getPropertyValue("color"),
+ `rgb(${hexToRGB(POPUP_TEXT_COLOR).join(", ")})`,
+ "Popup text color should have been themed"
+ );
+
+ // Ensure popup border color was set properly
+ testBorderColor(arrowContent, POPUP_BORDER_COLOR);
+
+ await closeIdentityPopup();
+ await extension.unload();
+ }
+ );
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js b/toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js
new file mode 100644
index 0000000000..d2baf6157b
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js
@@ -0,0 +1,173 @@
+"use strict";
+
+// This test checks whether applied WebExtension themes that attempt to change
+// popup properties are applied correctly to the autocomplete bar.
+const POPUP_COLOR_DARK = "#00A400";
+const POPUP_COLOR_BRIGHT = "#85A4FF";
+const POPUP_TEXT_COLOR_DARK = "#000000";
+const POPUP_TEXT_COLOR_BRIGHT = "#ffffff";
+const POPUP_SELECTED_COLOR = "#9400ff";
+const POPUP_SELECTED_TEXT_COLOR = "#09b9a6";
+
+const POPUP_URL_COLOR_DARK = "#0061e0";
+const POPUP_ACTION_COLOR_DARK = "#5b5b66";
+const POPUP_URL_COLOR_BRIGHT = "#00ddff";
+const POPUP_ACTION_COLOR_BRIGHT = "#bfbfc9";
+
+const SEARCH_TERM = "urlbar-reflows-" + Date.now();
+
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs",
+});
+
+add_setup(async function () {
+ await PlacesUtils.history.clear();
+ const NUM_VISITS = 10;
+ let visits = [];
+
+ for (let i = 0; i < NUM_VISITS; ++i) {
+ visits.push({
+ uri: `http://example.com/urlbar-reflows-${i}`,
+ title: `Reflow test for URL bar entry #${i} - ${SEARCH_TERM}`,
+ });
+ }
+
+ await PlacesTestUtils.addVisits(visits);
+
+ registerCleanupFunction(async function () {
+ await PlacesUtils.history.clear();
+ });
+});
+
+add_task(async function test_popup_url() {
+ // Load a manifest with popup_text being dark (bright background). Test for
+ // dark text properties.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ toolbar_field_focus: POPUP_COLOR_BRIGHT,
+ toolbar_field_text_focus: POPUP_TEXT_COLOR_DARK,
+ popup_highlight: POPUP_SELECTED_COLOR,
+ popup_highlight_text: POPUP_SELECTED_TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ let maxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults");
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:mozilla"
+ );
+ registerCleanupFunction(async function () {
+ await PlacesUtils.history.clear();
+ await BrowserTestUtils.removeTab(tab);
+ });
+
+ let visits = [];
+
+ for (let i = 0; i < maxResults; i++) {
+ visits.push({ uri: makeURI("http://example.com/autocomplete/?" + i) });
+ }
+
+ await PlacesTestUtils.addVisits(visits);
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ waitForFocus,
+ value: "example.com/autocomplete",
+ });
+ await UrlbarTestUtils.waitForAutocompleteResultAt(window, maxResults - 1);
+
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ maxResults,
+ "Should get maxResults=" + maxResults + " results"
+ );
+
+ // Set the selected attribute to true to test the highlight popup properties
+ UrlbarTestUtils.setSelectedRowIndex(window, 1);
+ let actionResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
+ let urlResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+ let resultCS = window.getComputedStyle(urlResult.element.row);
+
+ Assert.equal(
+ resultCS.backgroundColor,
+ `rgb(${hexToRGB(POPUP_SELECTED_COLOR).join(", ")})`,
+ `Popup highlight background color should be set to ${POPUP_SELECTED_COLOR}`
+ );
+
+ Assert.equal(
+ resultCS.color,
+ `rgb(${hexToRGB(POPUP_SELECTED_TEXT_COLOR).join(", ")})`,
+ `Popup highlight color should be set to ${POPUP_SELECTED_TEXT_COLOR}`
+ );
+
+ // Now set the index to somewhere not on the first two, so that we can test both
+ // url and action text colors.
+ UrlbarTestUtils.setSelectedRowIndex(window, 2);
+
+ Assert.equal(
+ window.getComputedStyle(urlResult.element.url).color,
+ `rgb(${hexToRGB(POPUP_URL_COLOR_DARK).join(", ")})`,
+ `Urlbar popup url color should be set to ${POPUP_URL_COLOR_DARK}`
+ );
+
+ Assert.equal(
+ window.getComputedStyle(actionResult.element.action).color,
+ `rgb(${hexToRGB(POPUP_ACTION_COLOR_DARK).join(", ")})`,
+ `Urlbar popup action color should be set to ${POPUP_ACTION_COLOR_DARK}`
+ );
+
+ await extension.unload();
+
+ // Load a manifest with popup_text being bright (dark background). Test for
+ // bright text properties.
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ toolbar_field_focus: POPUP_COLOR_DARK,
+ toolbar_field_text_focus: POPUP_TEXT_COLOR_BRIGHT,
+ popup_highlight: POPUP_SELECTED_COLOR,
+ popup_highlight_text: POPUP_SELECTED_TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ Assert.equal(
+ window.getComputedStyle(urlResult.element.url).color,
+ `rgb(${hexToRGB(POPUP_URL_COLOR_BRIGHT).join(", ")})`,
+ `Urlbar popup url color should be set to ${POPUP_URL_COLOR_BRIGHT}`
+ );
+
+ Assert.equal(
+ window.getComputedStyle(actionResult.element.action).color,
+ `rgb(${hexToRGB(POPUP_ACTION_COLOR_BRIGHT).join(", ")})`,
+ `Urlbar popup action color should be set to ${POPUP_ACTION_COLOR_BRIGHT}`
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js b/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js
new file mode 100644
index 0000000000..e88c78fa93
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js
@@ -0,0 +1,213 @@
+"use strict";
+
+add_task(async function test_support_theme_frame() {
+ const FRAME_COLOR = [71, 105, 91];
+ const TAB_TEXT_COLOR = [0, 0, 0];
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "face.png",
+ },
+ colors: {
+ frame: FRAME_COLOR,
+ tab_background_text: TAB_TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "face.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA),
+ },
+ });
+
+ await extension.startup();
+
+ let docEl = window.document.documentElement;
+ Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set");
+
+ Assert.ok(
+ docEl.hasAttribute("lwtheme-image"),
+ "LWT image attribute should be set"
+ );
+
+ Assert.equal(
+ docEl.getAttribute("lwtheme-brighttext"),
+ null,
+ "LWT text color attribute should not be set"
+ );
+
+ let toolbox = document.querySelector("#navigator-toolbox");
+ let toolboxCS = window.getComputedStyle(toolbox);
+
+ if (backgroundColorSetOnRoot()) {
+ let rootCS = window.getComputedStyle(docEl);
+ Assert.ok(
+ rootCS.backgroundImage.includes("face.png"),
+ `The backgroundImage should use face.png. Actual value is: ${toolboxCS.backgroundImage}`
+ );
+ Assert.equal(
+ rootCS.backgroundColor,
+ "rgb(" + FRAME_COLOR.join(", ") + ")",
+ "Expected correct background color"
+ );
+ } else {
+ Assert.ok(
+ toolboxCS.backgroundImage.includes("face.png"),
+ `The backgroundImage should use face.png. Actual value is: ${toolboxCS.backgroundImage}`
+ );
+ Assert.equal(
+ toolboxCS.backgroundColor,
+ "rgb(" + FRAME_COLOR.join(", ") + ")",
+ "Expected correct background color"
+ );
+ }
+ Assert.equal(
+ toolboxCS.color,
+ "rgb(" + TAB_TEXT_COLOR.join(", ") + ")",
+ "Expected correct text color"
+ );
+
+ await extension.unload();
+
+ Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set");
+
+ Assert.ok(
+ !docEl.hasAttribute("lwtheme-image"),
+ "LWT image attribute should not be set"
+ );
+
+ Assert.ok(
+ !docEl.hasAttribute("lwtheme-brighttext"),
+ "LWT text color attribute should not be set"
+ );
+});
+
+add_task(async function test_support_theme_frame_inactive() {
+ const FRAME_COLOR = [71, 105, 91];
+ const FRAME_COLOR_INACTIVE = [255, 0, 0];
+ const TAB_TEXT_COLOR = [207, 221, 192];
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: FRAME_COLOR,
+ frame_inactive: FRAME_COLOR_INACTIVE,
+ tab_background_text: TAB_TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ let docEl = window.document.documentElement;
+ let toolbox = document.querySelector("#navigator-toolbox");
+ let toolboxCS = window.getComputedStyle(toolbox);
+
+ if (backgroundColorSetOnRoot()) {
+ let rootCS = window.getComputedStyle(docEl);
+ Assert.equal(
+ rootCS.backgroundColor,
+ "rgb(" + FRAME_COLOR.join(", ") + ")",
+ "Window background is set to the colors.frame property"
+ );
+ } else {
+ Assert.equal(
+ toolboxCS.backgroundColor,
+ "rgb(" + FRAME_COLOR.join(", ") + ")",
+ "Window background is set to the colors.frame property"
+ );
+ }
+
+ // Now we'll open a new window to see if the inactive browser accent color changed
+ let window2 = await BrowserTestUtils.openNewBrowserWindow();
+ if (backgroundColorSetOnRoot()) {
+ let rootCS = window.getComputedStyle(docEl);
+ Assert.equal(
+ rootCS.backgroundColor,
+ "rgb(" + FRAME_COLOR_INACTIVE.join(", ") + ")",
+ `Inactive window root background color should be ${FRAME_COLOR_INACTIVE}`
+ );
+ } else {
+ Assert.equal(
+ toolboxCS.backgroundColor,
+ "rgb(" + FRAME_COLOR_INACTIVE.join(", ") + ")",
+ `Inactive window background color should be ${FRAME_COLOR_INACTIVE}`
+ );
+ }
+
+ await BrowserTestUtils.closeWindow(window2);
+ await extension.unload();
+
+ Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set");
+});
+
+add_task(async function test_lack_of_theme_frame_inactive() {
+ const FRAME_COLOR = [71, 105, 91];
+ const TAB_TEXT_COLOR = [207, 221, 192];
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: FRAME_COLOR,
+ tab_background_text: TAB_TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ let docEl = window.document.documentElement;
+ let toolbox = document.querySelector("#navigator-toolbox");
+ let toolboxCS = window.getComputedStyle(toolbox);
+
+ if (backgroundColorSetOnRoot()) {
+ let rootCS = window.getComputedStyle(docEl);
+ Assert.equal(
+ rootCS.backgroundColor,
+ "rgb(" + FRAME_COLOR.join(", ") + ")",
+ "Window background is set to the colors.frame property"
+ );
+ } else {
+ Assert.equal(
+ toolboxCS.backgroundColor,
+ "rgb(" + FRAME_COLOR.join(", ") + ")",
+ "Window background is set to the colors.frame property"
+ );
+ }
+
+ // Now we'll open a new window to make sure the inactive browser accent color stayed the same
+ let window2 = await BrowserTestUtils.openNewBrowserWindow();
+ if (backgroundColorSetOnRoot()) {
+ let rootCS = window.getComputedStyle(docEl);
+ Assert.equal(
+ rootCS.backgroundColor,
+ "rgb(" + FRAME_COLOR.join(", ") + ")",
+ "Inactive window background should not change if colors.frame_inactive isn't set"
+ );
+ } else {
+ Assert.equal(
+ toolboxCS.backgroundColor,
+ "rgb(" + FRAME_COLOR.join(", ") + ")",
+ "Inactive window background should not change if colors.frame_inactive isn't set"
+ );
+ }
+
+ await BrowserTestUtils.closeWindow(window2);
+ await extension.unload();
+
+ Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set");
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js
new file mode 100644
index 0000000000..4a379edfbf
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js
@@ -0,0 +1,203 @@
+"use strict";
+
+// This test checks whether browser.theme.getCurrent() works correctly in different
+// configurations and with different parameter.
+
+// PNG image data for a simple red dot.
+const BACKGROUND_1 =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
+// PNG image data for the Mozilla dino head.
+const BACKGROUND_2 =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==";
+
+add_task(async function test_get_current() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ const ACCENT_COLOR_1 = "#a14040";
+ const TEXT_COLOR_1 = "#fac96e";
+
+ const ACCENT_COLOR_2 = "#03fe03";
+ const TEXT_COLOR_2 = "#0ef325";
+
+ const theme1 = {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR_1,
+ tab_background_text: TEXT_COLOR_1,
+ },
+ };
+
+ const theme2 = {
+ images: {
+ theme_frame: "image2.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR_2,
+ tab_background_text: TEXT_COLOR_2,
+ },
+ };
+
+ function ensureWindowFocused(winId) {
+ browser.test.log("Waiting for focused window to be " + winId);
+ // eslint-disable-next-line no-async-promise-executor
+ return new Promise(async resolve => {
+ let listener = windowId => {
+ if (windowId === winId) {
+ browser.windows.onFocusChanged.removeListener(listener);
+ resolve();
+ }
+ };
+ // We first add a listener and then check whether the window is
+ // focused using .get(), because the .get() Promise resolving
+ // could race with the listener running, in which case we'd
+ // never be notified.
+ browser.windows.onFocusChanged.addListener(listener);
+ let { focused } = await browser.windows.get(winId);
+ if (focused) {
+ browser.windows.onFocusChanged.removeListener(listener);
+ resolve();
+ }
+ });
+ }
+
+ function testTheme1(returnedTheme) {
+ browser.test.assertTrue(
+ returnedTheme.images.theme_frame.includes("image1.png"),
+ "Theme 1 theme_frame image should be applied"
+ );
+ browser.test.assertEq(
+ ACCENT_COLOR_1,
+ returnedTheme.colors.frame,
+ "Theme 1 frame color should be applied"
+ );
+ browser.test.assertEq(
+ TEXT_COLOR_1,
+ returnedTheme.colors.tab_background_text,
+ "Theme 1 tab_background_text color should be applied"
+ );
+ }
+
+ function testTheme2(returnedTheme) {
+ browser.test.assertTrue(
+ returnedTheme.images.theme_frame.includes("image2.png"),
+ "Theme 2 theme_frame image should be applied"
+ );
+ browser.test.assertEq(
+ ACCENT_COLOR_2,
+ returnedTheme.colors.frame,
+ "Theme 2 frame color should be applied"
+ );
+ browser.test.assertEq(
+ TEXT_COLOR_2,
+ returnedTheme.colors.tab_background_text,
+ "Theme 2 tab_background_text color should be applied"
+ );
+ }
+
+ function testEmptyTheme(returnedTheme) {
+ browser.test.assertEq(
+ JSON.stringify({ colors: null, images: null, properties: null }),
+ JSON.stringify(returnedTheme),
+ JSON.stringify(returnedTheme, null, 2)
+ );
+ }
+
+ browser.test.log("Testing getCurrent() with initial unthemed window");
+ const firstWin = await browser.windows.getCurrent();
+ testEmptyTheme(await browser.theme.getCurrent());
+ testEmptyTheme(await browser.theme.getCurrent(firstWin.id));
+
+ browser.test.log("Testing getCurrent() with after theme.update()");
+ await browser.theme.update(theme1);
+ testTheme1(await browser.theme.getCurrent());
+ testTheme1(await browser.theme.getCurrent(firstWin.id));
+
+ browser.test.log(
+ "Testing getCurrent() with after theme.update(windowId)"
+ );
+ const secondWin = await browser.windows.create();
+ await ensureWindowFocused(secondWin.id);
+ await browser.theme.update(secondWin.id, theme2);
+ testTheme2(await browser.theme.getCurrent());
+ testTheme1(await browser.theme.getCurrent(firstWin.id));
+ testTheme2(await browser.theme.getCurrent(secondWin.id));
+
+ browser.test.log("Testing getCurrent() after window focus change");
+ let focusChanged = ensureWindowFocused(firstWin.id);
+ await browser.windows.update(firstWin.id, { focused: true });
+ await focusChanged;
+ testTheme1(await browser.theme.getCurrent());
+ testTheme1(await browser.theme.getCurrent(firstWin.id));
+ testTheme2(await browser.theme.getCurrent(secondWin.id));
+
+ browser.test.log(
+ "Testing getCurrent() after another window focus change"
+ );
+ focusChanged = ensureWindowFocused(secondWin.id);
+ await browser.windows.update(secondWin.id, { focused: true });
+ await focusChanged;
+ testTheme2(await browser.theme.getCurrent());
+ testTheme1(await browser.theme.getCurrent(firstWin.id));
+ testTheme2(await browser.theme.getCurrent(secondWin.id));
+
+ browser.test.log("Testing getCurrent() after theme.reset(windowId)");
+ await browser.theme.reset(firstWin.id);
+ testTheme2(await browser.theme.getCurrent());
+ testTheme1(await browser.theme.getCurrent(firstWin.id));
+ testTheme2(await browser.theme.getCurrent(secondWin.id));
+
+ browser.test.log(
+ "Testing getCurrent() after reset and window focus change"
+ );
+ focusChanged = ensureWindowFocused(firstWin.id);
+ await browser.windows.update(firstWin.id, { focused: true });
+ await focusChanged;
+ testTheme1(await browser.theme.getCurrent());
+ testTheme1(await browser.theme.getCurrent(firstWin.id));
+ testTheme2(await browser.theme.getCurrent(secondWin.id));
+
+ browser.test.log("Testing getCurrent() after theme.update(windowId)");
+ await browser.theme.update(firstWin.id, theme1);
+ testTheme1(await browser.theme.getCurrent());
+ testTheme1(await browser.theme.getCurrent(firstWin.id));
+ testTheme2(await browser.theme.getCurrent(secondWin.id));
+
+ browser.test.log("Testing getCurrent() after theme.reset()");
+ await browser.theme.reset();
+ testEmptyTheme(await browser.theme.getCurrent());
+ testEmptyTheme(await browser.theme.getCurrent(firstWin.id));
+ testEmptyTheme(await browser.theme.getCurrent(secondWin.id));
+
+ browser.test.log("Testing getCurrent() after closing a window");
+ await browser.windows.remove(secondWin.id);
+ testEmptyTheme(await browser.theme.getCurrent());
+ testEmptyTheme(await browser.theme.getCurrent(firstWin.id));
+
+ browser.test.log("Testing update calls with invalid window ID");
+ await browser.test.assertRejects(
+ browser.theme.reset(secondWin.id),
+ /Invalid window/,
+ "Invalid window should throw"
+ );
+ await browser.test.assertRejects(
+ browser.theme.update(secondWin.id, theme2),
+ /Invalid window/,
+ "Invalid window should throw"
+ );
+ browser.test.notifyPass("get_current");
+ },
+ manifest: {
+ permissions: ["theme"],
+ },
+ files: {
+ "image1.png": BACKGROUND_1,
+ "image2.png": BACKGROUND_2,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("get_current");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js
new file mode 100644
index 0000000000..34c7162810
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js
@@ -0,0 +1,154 @@
+"use strict";
+
+// This test checks whether browser.theme.onUpdated works correctly with different
+// types of dynamic theme updates.
+
+// PNG image data for a simple red dot.
+const BACKGROUND_1 =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
+// PNG image data for the Mozilla dino head.
+const BACKGROUND_2 =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==";
+
+add_task(async function test_on_updated() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ const ACCENT_COLOR_1 = "#a14040";
+ const TEXT_COLOR_1 = "#fac96e";
+
+ const ACCENT_COLOR_2 = "#03fe03";
+ const TEXT_COLOR_2 = "#0ef325";
+
+ const theme1 = {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR_1,
+ tab_background_text: TEXT_COLOR_1,
+ },
+ };
+
+ const theme2 = {
+ images: {
+ theme_frame: "image2.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR_2,
+ tab_background_text: TEXT_COLOR_2,
+ },
+ };
+
+ function testTheme1(returnedTheme) {
+ browser.test.assertTrue(
+ returnedTheme.images.theme_frame.includes("image1.png"),
+ "Theme 1 theme_frame image should be applied"
+ );
+ browser.test.assertEq(
+ ACCENT_COLOR_1,
+ returnedTheme.colors.frame,
+ "Theme 1 frame color should be applied"
+ );
+ browser.test.assertEq(
+ TEXT_COLOR_1,
+ returnedTheme.colors.tab_background_text,
+ "Theme 1 tab_background_text color should be applied"
+ );
+ }
+
+ function testTheme2(returnedTheme) {
+ browser.test.assertTrue(
+ returnedTheme.images.theme_frame.includes("image2.png"),
+ "Theme 2 theme_frame image should be applied"
+ );
+ browser.test.assertEq(
+ ACCENT_COLOR_2,
+ returnedTheme.colors.frame,
+ "Theme 2 frame color should be applied"
+ );
+ browser.test.assertEq(
+ TEXT_COLOR_2,
+ returnedTheme.colors.tab_background_text,
+ "Theme 2 tab_background_text color should be applied"
+ );
+ }
+
+ const firstWin = await browser.windows.getCurrent();
+ const secondWin = await browser.windows.create();
+
+ const onceThemeUpdated = () =>
+ new Promise(resolve => {
+ const listener = updateInfo => {
+ browser.theme.onUpdated.removeListener(listener);
+ resolve(updateInfo);
+ };
+ browser.theme.onUpdated.addListener(listener);
+ });
+
+ browser.test.log("Testing update with no windowId parameter");
+ let updateInfo1 = onceThemeUpdated();
+ await browser.theme.update(theme1);
+ updateInfo1 = await updateInfo1;
+ testTheme1(updateInfo1.theme);
+ browser.test.assertTrue(
+ !updateInfo1.windowId,
+ "No window id on first update"
+ );
+
+ browser.test.log("Testing update with windowId parameter");
+ let updateInfo2 = onceThemeUpdated();
+ await browser.theme.update(secondWin.id, theme2);
+ updateInfo2 = await updateInfo2;
+ testTheme2(updateInfo2.theme);
+ browser.test.assertEq(
+ secondWin.id,
+ updateInfo2.windowId,
+ "window id on second update"
+ );
+
+ browser.test.log("Testing reset with windowId parameter");
+ let updateInfo3 = onceThemeUpdated();
+ await browser.theme.reset(firstWin.id);
+ updateInfo3 = await updateInfo3;
+ browser.test.assertEq(
+ 0,
+ Object.keys(updateInfo3.theme).length,
+ "Empty theme given on reset"
+ );
+ browser.test.assertEq(
+ firstWin.id,
+ updateInfo3.windowId,
+ "window id on third update"
+ );
+
+ browser.test.log("Testing reset with no windowId parameter");
+ let updateInfo4 = onceThemeUpdated();
+ await browser.theme.reset();
+ updateInfo4 = await updateInfo4;
+ browser.test.assertEq(
+ 0,
+ Object.keys(updateInfo4.theme).length,
+ "Empty theme given on reset"
+ );
+ browser.test.assertTrue(
+ !updateInfo4.windowId,
+ "no window id on fourth update"
+ );
+
+ browser.test.log("Cleaning up test");
+ await browser.windows.remove(secondWin.id);
+ browser.test.notifyPass("onUpdated");
+ },
+ manifest: {
+ permissions: ["theme"],
+ },
+ files: {
+ "image1.png": BACKGROUND_1,
+ "image2.png": BACKGROUND_2,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("onUpdated");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js
new file mode 100644
index 0000000000..c6af66b5e5
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js
@@ -0,0 +1,217 @@
+"use strict";
+
+// PNG image data for a simple red dot.
+const BACKGROUND_1 =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
+const ACCENT_COLOR_1 = "#a14040";
+const TEXT_COLOR_1 = "#fac96e";
+
+// PNG image data for the Mozilla dino head.
+const BACKGROUND_2 =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==";
+const ACCENT_COLOR_2 = "#03fe03";
+const TEXT_COLOR_2 = "#0ef325";
+
+function hexToRGB(hex) {
+ hex = parseInt(hex.indexOf("#") > -1 ? hex.substring(1) : hex, 16);
+ return (
+ "rgb(" + [hex >> 16, (hex & 0x00ff00) >> 8, hex & 0x0000ff].join(", ") + ")"
+ );
+}
+
+function validateTheme(backgroundImage, accentColor, textColor, isLWT) {
+ let docEl = window.document.documentElement;
+ let rootCS = window.getComputedStyle(docEl);
+
+ let toolbox = document.querySelector("#navigator-toolbox");
+ let toolboxCS = window.getComputedStyle(toolbox);
+
+ if (isLWT) {
+ Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set");
+ Assert.equal(
+ docEl.getAttribute("lwtheme-brighttext"),
+ "true",
+ "LWT text color attribute should be set"
+ );
+ }
+
+ if (accentColor.startsWith("#")) {
+ accentColor = hexToRGB(accentColor);
+ }
+ if (textColor.startsWith("#")) {
+ textColor = hexToRGB(textColor);
+ }
+ if (backgroundColorSetOnRoot()) {
+ Assert.ok(
+ rootCS.backgroundImage.includes(backgroundImage),
+ "Expected correct background image"
+ );
+ Assert.equal(
+ rootCS.backgroundColor,
+ accentColor,
+ "Expected correct accent color"
+ );
+ } else {
+ Assert.ok(
+ toolboxCS.backgroundImage.includes(backgroundImage),
+ "Expected correct background image"
+ );
+ Assert.equal(
+ toolboxCS.backgroundColor,
+ accentColor,
+ "Expected correct accent color"
+ );
+ }
+
+ Assert.equal(rootCS.color, textColor, "Expected correct text color");
+}
+
+add_task(async function test_dynamic_theme_updates() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["theme"],
+ },
+ files: {
+ "image1.png": BACKGROUND_1,
+ "image2.png": BACKGROUND_2,
+ },
+ background() {
+ browser.test.onMessage.addListener((msg, details) => {
+ if (msg === "update-theme") {
+ browser.theme.update(details).then(() => {
+ browser.test.sendMessage("theme-updated");
+ });
+ } else {
+ browser.theme.reset().then(() => {
+ browser.test.sendMessage("theme-reset");
+ });
+ }
+ });
+ },
+ });
+
+ let rootCS = window.getComputedStyle(window.document.documentElement);
+ let toolboxCS = window.getComputedStyle(
+ window.document.documentElement.querySelector("#navigator-toolbox")
+ );
+ await extension.startup();
+
+ extension.sendMessage("update-theme", {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR_1,
+ tab_background_text: TEXT_COLOR_1,
+ },
+ });
+
+ await extension.awaitMessage("theme-updated");
+
+ validateTheme("image1.png", ACCENT_COLOR_1, TEXT_COLOR_1, true);
+
+ // Check with the LWT aliases (to update on Firefox 69, because the
+ // LWT aliases are going to be removed).
+ extension.sendMessage("update-theme", {
+ images: {
+ theme_frame: "image2.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR_2,
+ tab_background_text: TEXT_COLOR_2,
+ },
+ });
+
+ await extension.awaitMessage("theme-updated");
+
+ validateTheme("image2.png", ACCENT_COLOR_2, TEXT_COLOR_2, true);
+
+ extension.sendMessage("reset-theme");
+
+ await extension.awaitMessage("theme-reset");
+
+ let { color } = rootCS;
+ let { backgroundImage, backgroundColor } = toolboxCS;
+ if (backgroundColorSetOnRoot()) {
+ backgroundImage = rootCS.backgroundImage;
+ backgroundColor = rootCS.backgroundColor;
+ }
+ validateTheme(backgroundImage, backgroundColor, color, false);
+
+ await extension.unload();
+
+ let docEl = window.document.documentElement;
+ Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set");
+});
+
+add_task(async function test_dynamic_theme_updates_with_data_url() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["theme"],
+ },
+ background() {
+ browser.test.onMessage.addListener((msg, details) => {
+ if (msg === "update-theme") {
+ browser.theme.update(details).then(() => {
+ browser.test.sendMessage("theme-updated");
+ });
+ } else {
+ browser.theme.reset().then(() => {
+ browser.test.sendMessage("theme-reset");
+ });
+ }
+ });
+ },
+ });
+
+ let rootCS = window.getComputedStyle(window.document.documentElement);
+ let toolboxCS = window.getComputedStyle(
+ window.document.documentElement.querySelector("#navigator-toolbox")
+ );
+ await extension.startup();
+
+ extension.sendMessage("update-theme", {
+ images: {
+ theme_frame: BACKGROUND_1,
+ },
+ colors: {
+ frame: ACCENT_COLOR_1,
+ tab_background_text: TEXT_COLOR_1,
+ },
+ });
+
+ await extension.awaitMessage("theme-updated");
+
+ validateTheme(BACKGROUND_1, ACCENT_COLOR_1, TEXT_COLOR_1, true);
+
+ extension.sendMessage("update-theme", {
+ images: {
+ theme_frame: BACKGROUND_2,
+ },
+ colors: {
+ frame: ACCENT_COLOR_2,
+ tab_background_text: TEXT_COLOR_2,
+ },
+ });
+
+ await extension.awaitMessage("theme-updated");
+
+ validateTheme(BACKGROUND_2, ACCENT_COLOR_2, TEXT_COLOR_2, true);
+
+ extension.sendMessage("reset-theme");
+
+ await extension.awaitMessage("theme-reset");
+
+ let { color } = rootCS;
+ let { backgroundImage, backgroundColor } = toolboxCS;
+ if (backgroundColorSetOnRoot()) {
+ backgroundImage = rootCS.backgroundImage;
+ backgroundColor = rootCS.backgroundColor;
+ }
+ validateTheme(backgroundImage, backgroundColor, color, false);
+
+ await extension.unload();
+
+ let docEl = window.document.documentElement;
+ Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set");
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js b/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js
new file mode 100644
index 0000000000..02156b6cd8
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js
@@ -0,0 +1,450 @@
+"use strict";
+
+const { AddonSettings } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonSettings.sys.mjs"
+);
+
+// This test checks whether the theme experiments work
+add_task(async function test_experiment_static_theme() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ theme: {
+ colors: {
+ some_color_property: "#ff00ff",
+ },
+ images: {
+ some_image_property: "background.jpg",
+ },
+ properties: {
+ some_random_property: "no-repeat",
+ },
+ },
+ theme_experiment: {
+ colors: {
+ some_color_property: "--some-color-property",
+ },
+ images: {
+ some_image_property: "--some-image-property",
+ },
+ properties: {
+ some_random_property: "--some-random-property",
+ },
+ },
+ },
+ });
+
+ const root = window.document.documentElement;
+
+ is(
+ root.style.getPropertyValue("--some-color-property"),
+ "",
+ "Color property should be unset"
+ );
+ is(
+ root.style.getPropertyValue("--some-image-property"),
+ "",
+ "Image property should be unset"
+ );
+ is(
+ root.style.getPropertyValue("--some-random-property"),
+ "",
+ "Generic Property should be unset."
+ );
+
+ await extension.startup();
+
+ const testExperimentApplied = rootEl => {
+ if (AddonSettings.EXPERIMENTS_ENABLED) {
+ is(
+ rootEl.style.getPropertyValue("--some-color-property"),
+ hexToCSS("#ff00ff"),
+ "Color property should be parsed and set."
+ );
+ ok(
+ rootEl.style
+ .getPropertyValue("--some-image-property")
+ .startsWith("url("),
+ "Image property should be parsed."
+ );
+ ok(
+ rootEl.style
+ .getPropertyValue("--some-image-property")
+ .endsWith("background.jpg)"),
+ "Image property should be set."
+ );
+ is(
+ rootEl.style.getPropertyValue("--some-random-property"),
+ "no-repeat",
+ "Generic Property should be set."
+ );
+ } else {
+ is(
+ rootEl.style.getPropertyValue("--some-color-property"),
+ "",
+ "Color property should be unset"
+ );
+ is(
+ rootEl.style.getPropertyValue("--some-image-property"),
+ "",
+ "Image property should be unset"
+ );
+ is(
+ rootEl.style.getPropertyValue("--some-random-property"),
+ "",
+ "Generic Property should be unset."
+ );
+ }
+ };
+
+ info("Testing that current window updated with the experiment applied");
+ testExperimentApplied(root);
+
+ info("Testing that new window initialized with the experiment applied");
+ const newWindow = await BrowserTestUtils.openNewBrowserWindow();
+ const newWindowRoot = newWindow.document.documentElement;
+ testExperimentApplied(newWindowRoot);
+
+ await extension.unload();
+
+ info("Testing that both windows unapplied the experiment");
+ for (const rootEl of [root, newWindowRoot]) {
+ is(
+ rootEl.style.getPropertyValue("--some-color-property"),
+ "",
+ "Color property should be unset"
+ );
+ is(
+ rootEl.style.getPropertyValue("--some-image-property"),
+ "",
+ "Image property should be unset"
+ );
+ is(
+ rootEl.style.getPropertyValue("--some-random-property"),
+ "",
+ "Generic Property should be unset."
+ );
+ }
+ await BrowserTestUtils.closeWindow(newWindow);
+});
+
+add_task(async function test_experiment_dynamic_theme() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ permissions: ["theme"],
+ theme_experiment: {
+ colors: {
+ some_color_property: "--some-color-property",
+ },
+ images: {
+ some_image_property: "--some-image-property",
+ },
+ properties: {
+ some_random_property: "--some-random-property",
+ },
+ },
+ },
+ background() {
+ const theme = {
+ colors: {
+ some_color_property: "#ff00ff",
+ },
+ images: {
+ some_image_property: "background.jpg",
+ },
+ properties: {
+ some_random_property: "no-repeat",
+ },
+ };
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "update-theme") {
+ browser.theme.update(theme).then(() => {
+ browser.test.sendMessage("theme-updated");
+ });
+ } else {
+ browser.theme.reset().then(() => {
+ browser.test.sendMessage("theme-reset");
+ });
+ }
+ });
+ },
+ });
+
+ await extension.startup();
+
+ const root = window.document.documentElement;
+
+ is(
+ root.style.getPropertyValue("--some-color-property"),
+ "",
+ "Color property should be unset"
+ );
+ is(
+ root.style.getPropertyValue("--some-image-property"),
+ "",
+ "Image property should be unset"
+ );
+ is(
+ root.style.getPropertyValue("--some-random-property"),
+ "",
+ "Generic Property should be unset."
+ );
+
+ extension.sendMessage("update-theme");
+ await extension.awaitMessage("theme-updated");
+
+ const testExperimentApplied = rootEl => {
+ if (AddonSettings.EXPERIMENTS_ENABLED) {
+ is(
+ rootEl.style.getPropertyValue("--some-color-property"),
+ hexToCSS("#ff00ff"),
+ "Color property should be parsed and set."
+ );
+ ok(
+ rootEl.style
+ .getPropertyValue("--some-image-property")
+ .startsWith("url("),
+ "Image property should be parsed."
+ );
+ ok(
+ rootEl.style
+ .getPropertyValue("--some-image-property")
+ .endsWith("background.jpg)"),
+ "Image property should be set."
+ );
+ is(
+ rootEl.style.getPropertyValue("--some-random-property"),
+ "no-repeat",
+ "Generic Property should be set."
+ );
+ } else {
+ is(
+ rootEl.style.getPropertyValue("--some-color-property"),
+ "",
+ "Color property should be unset"
+ );
+ is(
+ rootEl.style.getPropertyValue("--some-image-property"),
+ "",
+ "Image property should be unset"
+ );
+ is(
+ rootEl.style.getPropertyValue("--some-random-property"),
+ "",
+ "Generic Property should be unset."
+ );
+ }
+ };
+ testExperimentApplied(root);
+
+ const newWindow = await BrowserTestUtils.openNewBrowserWindow();
+ const newWindowRoot = newWindow.document.documentElement;
+
+ testExperimentApplied(newWindowRoot);
+
+ extension.sendMessage("reset-theme");
+ await extension.awaitMessage("theme-reset");
+
+ for (const rootEl of [root, newWindowRoot]) {
+ is(
+ rootEl.style.getPropertyValue("--some-color-property"),
+ "",
+ "Color property should be unset"
+ );
+ is(
+ rootEl.style.getPropertyValue("--some-image-property"),
+ "",
+ "Image property should be unset"
+ );
+ is(
+ rootEl.style.getPropertyValue("--some-random-property"),
+ "",
+ "Generic Property should be unset."
+ );
+ }
+
+ extension.sendMessage("update-theme");
+ await extension.awaitMessage("theme-updated");
+
+ testExperimentApplied(root);
+ testExperimentApplied(newWindowRoot);
+
+ await extension.unload();
+
+ for (const rootEl of [root, newWindowRoot]) {
+ is(
+ rootEl.style.getPropertyValue("--some-color-property"),
+ "",
+ "Color property should be unset"
+ );
+ is(
+ rootEl.style.getPropertyValue("--some-image-property"),
+ "",
+ "Image property should be unset"
+ );
+ is(
+ rootEl.style.getPropertyValue("--some-random-property"),
+ "",
+ "Generic Property should be unset."
+ );
+ }
+
+ await BrowserTestUtils.closeWindow(newWindow);
+});
+
+add_task(async function test_experiment_stylesheet() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ theme: {
+ colors: {
+ menu_button_background: "#ff00ff",
+ },
+ },
+ theme_experiment: {
+ stylesheet: "experiment.css",
+ colors: {
+ menu_button_background: "--menu-button-background",
+ },
+ },
+ },
+ files: {
+ "experiment.css": `#PanelUI-menu-button {
+ background-color: var(--menu-button-background);
+ fill: white;
+ }`,
+ },
+ });
+
+ const root = window.document.documentElement;
+ const menuButton = document.getElementById("PanelUI-menu-button");
+ const computedStyle = window.getComputedStyle(menuButton);
+ const expectedColor = hexToCSS("#ff00ff");
+ const expectedFill = hexToCSS("#ffffff");
+
+ is(
+ root.style.getPropertyValue("--menu-button-background"),
+ "",
+ "Variable should be unset"
+ );
+ isnot(
+ computedStyle.backgroundColor,
+ expectedColor,
+ "Menu button should not have custom background"
+ );
+ isnot(
+ computedStyle.fill,
+ expectedFill,
+ "Menu button should not have stylesheet fill"
+ );
+
+ await extension.startup();
+
+ if (AddonSettings.EXPERIMENTS_ENABLED) {
+ // Wait for stylesheet load.
+ await BrowserTestUtils.waitForCondition(
+ () => computedStyle.fill === expectedFill
+ );
+
+ is(
+ root.style.getPropertyValue("--menu-button-background"),
+ expectedColor,
+ "Variable should be parsed and set."
+ );
+ is(
+ computedStyle.backgroundColor,
+ expectedColor,
+ "Menu button should be have correct background"
+ );
+ is(
+ computedStyle.fill,
+ expectedFill,
+ "Menu button should be have correct fill"
+ );
+ } else {
+ is(
+ root.style.getPropertyValue("--menu-button-background"),
+ "",
+ "Variable should be unset"
+ );
+ isnot(
+ computedStyle.backgroundColor,
+ expectedColor,
+ "Menu button should not have custom background"
+ );
+ isnot(
+ computedStyle.fill,
+ expectedFill,
+ "Menu button should not have stylesheet fill"
+ );
+ }
+
+ await extension.unload();
+
+ is(
+ root.style.getPropertyValue("--menu-button-background"),
+ "",
+ "Variable should be unset"
+ );
+ isnot(
+ computedStyle.backgroundColor,
+ expectedColor,
+ "Menu button should not have custom background"
+ );
+ isnot(
+ computedStyle.fill,
+ expectedFill,
+ "Menu button should not have stylesheet fill"
+ );
+});
+
+// This test checks whether the theme experiments are allowed for non privileged
+// theme installed non-temporarily if AddonSettings.EXPERIMENTS_ENABLED is true.
+add_task(async function test_experiment_installed_non_temporarily() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.experiments.enabled", true]],
+ });
+
+ if (!AddonSettings.EXPERIMENTS_ENABLED) {
+ info(
+ "Skipping test case on build where AddonSettings.EXPERIMENTS_ENABLED is false"
+ );
+ return;
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ some_color_property: "#ff00ff",
+ },
+ },
+ theme_experiment: {
+ colors: {
+ some_color_property: "--some-color-property",
+ },
+ },
+ },
+ });
+
+ const root = window.document.documentElement;
+
+ is(
+ root.style.getPropertyValue("--some-color-property"),
+ "",
+ "Color property should be unset"
+ );
+
+ await extension.startup();
+
+ is(
+ root.style.getPropertyValue("--some-color-property"),
+ hexToCSS("#ff00ff"),
+ "Color property should be parsed and set."
+ );
+
+ await extension.unload();
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js b/toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js
new file mode 100644
index 0000000000..a24c90615b
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js
@@ -0,0 +1,227 @@
+"use strict";
+
+// This test checks whether applied WebExtension themes that attempt to change
+// the toolbar and toolbar_field properties also theme the findbar.
+
+function assertHasNoBorders(element) {
+ let cs = window.getComputedStyle(element);
+ Assert.equal(cs.borderTopWidth, "0px", "should have no top border");
+ Assert.equal(cs.borderRightWidth, "0px", "should have no right border");
+ Assert.equal(cs.borderBottomWidth, "0px", "should have no bottom border");
+ Assert.equal(cs.borderLeftWidth, "0px", "should have no left border");
+}
+
+add_task(async function test_support_toolbar_properties_on_findbar() {
+ const TOOLBAR_COLOR = "#ff00ff";
+ const TOOLBAR_TEXT_COLOR = "#9400ff";
+ const ACCENT_COLOR_INACTIVE = "#ffff00";
+ // 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 extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ frame_inactive: ACCENT_COLOR_INACTIVE,
+ tab_background_text: TEXT_COLOR,
+ toolbar: TOOLBAR_COLOR,
+ bookmark_text: TOOLBAR_TEXT_COLOR,
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+ await gBrowser.getFindBar();
+
+ let findbar_button = gFindBar.getElement("highlight");
+
+ info("Checking findbar background is set as toolbar color");
+ Assert.equal(
+ window.getComputedStyle(gFindBar).backgroundColor,
+ hexToCSS(ACCENT_COLOR),
+ "Findbar background color should be the same as toolbar background color."
+ );
+
+ info("Checking findbar and checkbox text color use toolbar text color");
+ const rgbColor = hexToCSS(TOOLBAR_TEXT_COLOR);
+ Assert.equal(
+ window.getComputedStyle(gFindBar).color,
+ rgbColor,
+ "Findbar text color should be the same as toolbar text color."
+ );
+ Assert.equal(
+ window.getComputedStyle(findbar_button).color,
+ rgbColor,
+ "Findbar checkbox text color should be toolbar text color."
+ );
+
+ // Open a new window to check frame_inactive
+ let window2 = await BrowserTestUtils.openNewBrowserWindow();
+ Assert.equal(
+ window.getComputedStyle(gFindBar).backgroundColor,
+ hexToCSS(ACCENT_COLOR_INACTIVE),
+ "Findbar background changed in inactive window."
+ );
+ await BrowserTestUtils.closeWindow(window2);
+
+ await extension.unload();
+});
+
+add_task(async function test_support_toolbar_field_properties_on_findbar() {
+ let findbar_prev_button = gFindBar.getElement("find-previous");
+ let findbar_next_button = gFindBar.getElement("find-next");
+
+ assertHasNoBorders(findbar_prev_button);
+ assertHasNoBorders(findbar_next_button);
+
+ const TOOLBAR_FIELD_COLOR = "#ff00ff";
+ const TOOLBAR_FIELD_TEXT_COLOR = "#9400ff";
+ const TOOLBAR_FIELD_BORDER_COLOR = "#ffffff";
+ // 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 extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ toolbar_field: TOOLBAR_FIELD_COLOR,
+ toolbar_field_text: TOOLBAR_FIELD_TEXT_COLOR,
+ toolbar_field_border: TOOLBAR_FIELD_BORDER_COLOR,
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+ await gBrowser.getFindBar();
+
+ let findbar_textbox = gFindBar.getElement("findbar-textbox");
+
+ info(
+ "Checking findbar textbox background is set as toolbar field background color"
+ );
+ Assert.equal(
+ window.getComputedStyle(findbar_textbox).backgroundColor,
+ hexToCSS(TOOLBAR_FIELD_COLOR),
+ "Findbar textbox background color should be the same as toolbar field color."
+ );
+
+ info("Checking findbar textbox color is set as toolbar field text color");
+ Assert.equal(
+ window.getComputedStyle(findbar_textbox).color,
+ hexToCSS(TOOLBAR_FIELD_TEXT_COLOR),
+ "Findbar textbox text color should be the same as toolbar field text color."
+ );
+ testBorderColor(findbar_textbox, TOOLBAR_FIELD_BORDER_COLOR);
+
+ assertHasNoBorders(findbar_prev_button);
+ assertHasNoBorders(findbar_next_button);
+
+ await extension.unload();
+});
+
+// Test that theme properties are applied with a theme_frame
+add_task(async function test_toolbar_properties_on_findbar_with_theme_frame() {
+ const TOOLBAR_COLOR = "#ff00ff";
+ const TOOLBAR_TEXT_COLOR = "#9400ff";
+ // 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 extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ toolbar: TOOLBAR_COLOR,
+ bookmark_text: TOOLBAR_TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+ await gBrowser.getFindBar();
+
+ let findbar_button = gFindBar.getElement("highlight");
+
+ info("Checking findbar background is set as toolbar color");
+ Assert.equal(
+ window.getComputedStyle(gFindBar).backgroundColor,
+ hexToCSS(ACCENT_COLOR),
+ "Findbar background color should be set by theme."
+ );
+
+ info("Checking findbar and button text color is set as toolbar text color");
+ Assert.equal(
+ window.getComputedStyle(gFindBar).color,
+ hexToCSS(TOOLBAR_TEXT_COLOR),
+ "Findbar text color should be set by theme."
+ );
+ Assert.equal(
+ window.getComputedStyle(findbar_button).color,
+ hexToCSS(TOOLBAR_TEXT_COLOR),
+ "Findbar button text color should be set by theme."
+ );
+
+ await extension.unload();
+});
+
+add_task(
+ async function test_toolbar_field_properties_on_findbar_with_theme_frame() {
+ const TOOLBAR_FIELD_COLOR = "#ff00ff";
+ const TOOLBAR_FIELD_TEXT_COLOR = "#9400ff";
+ const TOOLBAR_FIELD_BORDER_COLOR = "#ffffff";
+ // 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 extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ toolbar_field: TOOLBAR_FIELD_COLOR,
+ toolbar_field_text: TOOLBAR_FIELD_TEXT_COLOR,
+ toolbar_field_border: TOOLBAR_FIELD_BORDER_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+ await gBrowser.getFindBar();
+
+ let findbar_textbox = gFindBar.getElement("findbar-textbox");
+
+ Assert.equal(
+ window.getComputedStyle(findbar_textbox).backgroundColor,
+ hexToCSS(TOOLBAR_FIELD_COLOR),
+ "Findbar textbox background color should be set by theme."
+ );
+
+ Assert.equal(
+ window.getComputedStyle(findbar_textbox).color,
+ hexToCSS(TOOLBAR_FIELD_TEXT_COLOR),
+ "Findbar textbox text color should be set by theme."
+ );
+
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js b/toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js
new file mode 100644
index 0000000000..587c5d4efe
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js
@@ -0,0 +1,151 @@
+"use strict";
+
+// This test checks whether browser.theme.getCurrent() works correctly when theme
+// does not originate from extension querying the theme.
+const THEME = {
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+};
+
+add_task(async function test_getcurrent() {
+ const theme = ExtensionTestUtils.loadExtension(THEME);
+ const extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.theme.onUpdated.addListener(() => {
+ browser.theme.getCurrent().then(theme => {
+ browser.test.sendMessage("theme-updated", theme);
+ if (!theme?.images) {
+ return;
+ }
+
+ // Try to access the theme_frame image
+ fetch(theme.images.theme_frame)
+ .then(() => {
+ browser.test.sendMessage("theme-image", { success: true });
+ })
+ .catch(e => {
+ browser.test.sendMessage("theme-image", {
+ success: false,
+ error: e.message,
+ });
+ });
+ });
+ });
+ },
+ });
+
+ await extension.startup();
+
+ info("Testing getCurrent after static theme startup");
+ let updatedPromise = extension.awaitMessage("theme-updated");
+ let imageLoaded = extension.awaitMessage("theme-image");
+ await theme.startup();
+ let receivedTheme = await updatedPromise;
+ Assert.ok(
+ receivedTheme.images.theme_frame.includes("image1.png"),
+ "getCurrent returns correct theme_frame image"
+ );
+ Assert.equal(
+ receivedTheme.colors.frame,
+ ACCENT_COLOR,
+ "getCurrent returns correct frame color"
+ );
+ Assert.equal(
+ receivedTheme.colors.tab_background_text,
+ TEXT_COLOR,
+ "getCurrent returns correct tab_background_text color"
+ );
+ Assert.deepEqual(await imageLoaded, { success: true }, "theme image loaded");
+
+ info("Testing getCurrent after static theme unload");
+ updatedPromise = extension.awaitMessage("theme-updated");
+ await theme.unload();
+ receivedTheme = await updatedPromise;
+ Assert.equal(
+ JSON.stringify({ colors: null, images: null, properties: null }),
+ JSON.stringify(receivedTheme),
+ "getCurrent returns empty theme"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_getcurrent_privateBrowsing() {
+ const theme = ExtensionTestUtils.loadExtension(THEME);
+
+ const extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ sidebar_action: {
+ default_panel: "sidebar.html",
+ },
+ },
+ // We don't want the sidebar to automatically open on extension startup.
+ startupReason: "APP_STARTUP",
+ files: {
+ "sidebar.html": `<!DOCTYPE html>
+ <html>
+ <body>
+ Test Extension Sidebar
+ <script src="sidebar.js"></script>
+ </body>
+ </html>
+ `,
+ "sidebar.js": function () {
+ browser.theme.getCurrent().then(theme => {
+ if (!theme?.images) {
+ browser.test.fail(
+ `Missing expected images from theme.getCurrent result`
+ );
+ return;
+ }
+
+ // Try to access the theme_frame image
+ fetch(theme.images.theme_frame)
+ .then(() => {
+ browser.test.sendMessage("theme-image", { success: true });
+ })
+ .catch(e => {
+ browser.test.sendMessage("theme-image", {
+ success: false,
+ error: e.message,
+ });
+ });
+ });
+ },
+ },
+ });
+
+ await extension.startup();
+ await theme.startup();
+
+ const privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ const { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+ );
+ const { makeWidgetId } = ExtensionCommon;
+ privateWin.SidebarUI.show(`${makeWidgetId(extension.id)}-sidebar-action`);
+
+ let imageLoaded = extension.awaitMessage("theme-image");
+ Assert.deepEqual(await imageLoaded, { success: true }, "theme image loaded");
+
+ await extension.unload();
+ await theme.unload();
+
+ await BrowserTestUtils.closeWindow(privateWin);
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js b/toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js
new file mode 100644
index 0000000000..5a0d1c7a8d
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js
@@ -0,0 +1,63 @@
+"use strict";
+
+// This test checks whether applied WebExtension themes that attempt to change
+// the color of the font and background in a selection are applied properly.
+const { CustomizableUITestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs"
+);
+let gCUITestUtils = new CustomizableUITestUtils(window);
+add_setup(async function () {
+ await gCUITestUtils.addSearchBar();
+ await gFindBarPromise;
+ registerCleanupFunction(() => {
+ gFindBar.close();
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+add_task(async function test_support_selection() {
+ const HIGHLIGHT_TEXT_COLOR = "#9400FF";
+ const HIGHLIGHT_COLOR = "#F89919";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ toolbar_field_highlight: HIGHLIGHT_COLOR,
+ toolbar_field_highlight_text: HIGHLIGHT_TEXT_COLOR,
+ },
+ },
+ },
+ });
+ await extension.startup();
+
+ let fields = [
+ gURLBar.inputField,
+ document.querySelector("#searchbar .searchbar-textbox"),
+ document.querySelector(".findbar-textbox"),
+ ].filter(field => {
+ let bounds = field.getBoundingClientRect();
+ return bounds.width > 0 && bounds.height > 0;
+ });
+
+ Assert.equal(fields.length, 3, "Should be testing three elements");
+
+ info(
+ `Checking background colors and colors for ${fields.length} toolbar input fields.`
+ );
+ for (let field of fields) {
+ info(`Testing ${field.id || field.className}`);
+ field.focus();
+ Assert.equal(
+ window.getComputedStyle(field, "::selection").backgroundColor,
+ hexToCSS(HIGHLIGHT_COLOR),
+ "Input selection background should be set."
+ );
+ Assert.equal(
+ window.getComputedStyle(field, "::selection").color,
+ hexToCSS(HIGHLIGHT_TEXT_COLOR),
+ "Input selection color should be set."
+ );
+ }
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js b/toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js
new file mode 100644
index 0000000000..d9beb0f9a8
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js
@@ -0,0 +1,77 @@
+"use strict";
+
+add_task(async function test_theme_incognito_not_allowed() {
+ let windowExtension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ async background() {
+ const theme = {
+ colors: {
+ frame: "black",
+ tab_background_text: "black",
+ },
+ };
+ let window = await browser.windows.create({ incognito: true });
+ browser.test.onMessage.addListener(async message => {
+ if (message == "update") {
+ browser.theme.update(window.id, theme);
+ return;
+ }
+ await browser.windows.remove(window.id);
+ browser.test.sendMessage("done");
+ });
+ browser.test.sendMessage("ready", window.id);
+ },
+ manifest: {
+ permissions: ["theme"],
+ },
+ });
+ await windowExtension.startup();
+ let wId = await windowExtension.awaitMessage("ready");
+
+ async function background(windowId) {
+ const theme = {
+ colors: {
+ frame: "black",
+ tab_background_text: "black",
+ },
+ };
+
+ browser.theme.onUpdated.addListener(info => {
+ browser.test.log("got theme onChanged");
+ browser.test.fail("theme");
+ });
+ await browser.test.assertRejects(
+ browser.theme.getCurrent(windowId),
+ /Invalid window ID/,
+ "API should reject getting window theme"
+ );
+ await browser.test.assertRejects(
+ browser.theme.update(windowId, theme),
+ /Invalid window ID/,
+ "API should reject updating theme"
+ );
+ await browser.test.assertRejects(
+ browser.theme.reset(windowId),
+ /Invalid window ID/,
+ "API should reject reseting theme on window"
+ );
+
+ browser.test.sendMessage("start");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${background})(${wId})`,
+ manifest: {
+ permissions: ["theme"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("start");
+ windowExtension.sendMessage("update");
+
+ windowExtension.sendMessage("close");
+ await windowExtension.awaitMessage("done");
+ await windowExtension.unload();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js b/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js
new file mode 100644
index 0000000000..4293e5eb19
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js
@@ -0,0 +1,69 @@
+"use strict";
+
+const DEFAULT_THEME_BG_COLOR = "rgb(255, 255, 255)";
+const DEFAULT_THEME_TEXT_COLOR = "rgb(0, 0, 0)";
+
+add_task(async function test_deprecated_LWT_properties_ignored() {
+ // This test uses deprecated theme properties, so warnings are expected.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ headerURL: "image1.png",
+ },
+ colors: {
+ accentcolor: ACCENT_COLOR,
+ textcolor: TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ let docEl = window.document.documentElement;
+ let docStyle = window.getComputedStyle(docEl);
+ let navigatorStyle = window.getComputedStyle(
+ docEl.querySelector("#navigator-toolbox")
+ );
+
+ Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set");
+ Assert.ok(
+ !docEl.hasAttribute("lwtheme-image"),
+ "LWT image attribute should not be set on deprecated headerURL alias"
+ );
+ Assert.ok(
+ !docEl.getAttribute("lwtheme-brighttext"),
+ "LWT text color attribute should not be set on deprecated textcolor alias"
+ );
+
+ if (backgroundColorSetOnRoot()) {
+ let rootCS = window.getComputedStyle(docEl);
+ Assert.equal(
+ rootCS.backgroundColor,
+ DEFAULT_THEME_BG_COLOR,
+ "Expected default theme background color"
+ );
+ } else {
+ Assert.equal(
+ navigatorStyle.backgroundColor,
+ DEFAULT_THEME_BG_COLOR,
+ "Expected default theme background color"
+ );
+ }
+
+ Assert.equal(
+ docStyle.color,
+ DEFAULT_THEME_TEXT_COLOR,
+ "Expected default theme text color"
+ );
+
+ await extension.unload();
+
+ Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set");
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js b/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js
new file mode 100644
index 0000000000..58088ce6a0
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js
@@ -0,0 +1,287 @@
+"use strict";
+
+add_task(async function test_support_backgrounds_position() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "face1.png",
+ additional_backgrounds: ["face2.png", "face2.png", "face2.png"],
+ },
+ colors: {
+ frame: `rgb(${FRAME_COLOR.join(",")})`,
+ tab_background_text: `rgb(${TAB_BACKGROUND_TEXT_COLOR.join(",")})`,
+ },
+ properties: {
+ additional_backgrounds_alignment: [
+ "left top",
+ "center top",
+ "right bottom",
+ ],
+ },
+ },
+ },
+ files: {
+ "face1.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA),
+ "face2.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA),
+ },
+ });
+
+ await extension.startup();
+
+ let docEl = window.document.documentElement;
+ let toolbox = document.querySelector("#navigator-toolbox");
+
+ Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set");
+ Assert.equal(
+ docEl.getAttribute("lwtheme-brighttext"),
+ "true",
+ "LWT text color attribute should be set"
+ );
+
+ let toolboxCS = window.getComputedStyle(toolbox);
+ let toolboxBgImage = toolboxCS.backgroundImage.split(",")[0].trim();
+ if (backgroundColorSetOnRoot()) {
+ let rootCS = window.getComputedStyle(docEl);
+ let rootBgImage = rootCS.backgroundImage.split(",")[0].trim();
+ Assert.ok(
+ rootBgImage.includes("face1.png"),
+ `The backgroundImage should use face1.png. Actual value is: ${rootBgImage}`
+ );
+ Assert.equal(
+ toolboxCS.backgroundImage,
+ Array(3).fill(toolboxBgImage).join(", "),
+ "The backgroundImage should use face2.png three times."
+ );
+ Assert.equal(
+ toolboxCS.backgroundPosition,
+ "0% 0%, 50% 0%, 100% 100%",
+ "The backgroundPosition should use the three values provided."
+ );
+ } else {
+ Assert.equal(
+ toolboxCS.backgroundImage,
+ [1, 2, 2, 2]
+ .map(num => toolboxBgImage.replace(/face[\d]*/, `face${num}`))
+ .join(", "),
+ "The backgroundImage should use face1.png once and face2.png three times."
+ );
+ Assert.equal(
+ toolboxCS.backgroundPosition,
+ "100% 0%, 0% 0%, 50% 0%, 100% 100%",
+ "The backgroundPosition should use the three values provided, preceded by the default for theme_frame."
+ );
+ /**
+ * We expect duplicate background-repeat values because we apply `no-repeat`
+ * once for theme_frame, and again as the default value of
+ * --lwt-background-tiling.
+ */
+ Assert.equal(
+ toolboxCS.backgroundRepeat,
+ "no-repeat, no-repeat",
+ "The backgroundPosition should use the default value."
+ );
+ }
+
+ await extension.unload();
+
+ Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set");
+ toolboxCS = window.getComputedStyle(toolbox);
+
+ // Styles should've reverted to their initial values.
+ if (backgroundColorSetOnRoot()) {
+ let rootCS = window.getComputedStyle(docEl);
+ Assert.equal(rootCS.backgroundImage, "none");
+ Assert.equal(rootCS.backgroundPosition, "0% 0%");
+ Assert.equal(rootCS.backgroundRepeat, "repeat");
+ }
+ Assert.equal(toolboxCS.backgroundImage, "none");
+ Assert.equal(toolboxCS.backgroundPosition, "0% 0%");
+ Assert.equal(toolboxCS.backgroundRepeat, "repeat");
+});
+
+add_task(async function test_support_backgrounds_repeat() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "face0.png",
+ additional_backgrounds: ["face1.png", "face2.png", "face3.png"],
+ },
+ colors: {
+ frame: FRAME_COLOR,
+ tab_background_text: TAB_BACKGROUND_TEXT_COLOR,
+ },
+ properties: {
+ additional_backgrounds_tiling: ["repeat-x", "repeat-y", "repeat"],
+ },
+ },
+ },
+ files: {
+ "face0.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA),
+ "face1.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA),
+ "face2.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA),
+ "face3.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA),
+ },
+ });
+
+ await extension.startup();
+
+ let docEl = window.document.documentElement;
+ let toolbox = document.querySelector("#navigator-toolbox");
+
+ Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set");
+ Assert.equal(
+ docEl.getAttribute("lwtheme-brighttext"),
+ "true",
+ "LWT text color attribute should be set"
+ );
+
+ let toolboxCS = window.getComputedStyle(toolbox);
+ if (backgroundColorSetOnRoot()) {
+ let rootCS = window.getComputedStyle(docEl);
+ let rootImage = rootCS.backgroundImage.split(",")[0].trim();
+ Assert.ok(
+ rootImage.includes("face0.png"),
+ `The backgroundImage should use face.png. Actual value is: ${rootImage}`
+ );
+ Assert.equal(
+ [1, 2, 3]
+ .map(num => rootImage.replace(/face[\d]*/, `face${num}`))
+ .join(", "),
+ toolboxCS.backgroundImage,
+ "The backgroundImage should use face.png three times."
+ );
+ Assert.equal(
+ rootCS.backgroundPosition,
+ "100% 0%, 100% 0%",
+ "The backgroundPosition should use the default value for root."
+ );
+ Assert.equal(
+ toolboxCS.backgroundPosition,
+ "100% 0%",
+ "The backgroundPosition should use the default value for navigator-toolbox."
+ );
+ Assert.equal(
+ rootCS.backgroundRepeat,
+ "no-repeat, repeat-x, repeat-y, repeat",
+ "The backgroundRepeat should use the default values for root."
+ );
+ Assert.equal(
+ toolboxCS.backgroundRepeat,
+ "repeat-x, repeat-y, repeat",
+ "The backgroundRepeat should use the three values provided for navigator-toolbox."
+ );
+ } else {
+ let toolboxImage = toolboxCS.backgroundImage.split(",")[0].trim();
+ Assert.equal(
+ [0, 1, 2, 3]
+ .map(num => toolboxImage.replace(/face[\d]*/, `face${num}`))
+ .join(", "),
+ toolboxCS.backgroundImage,
+ "The backgroundImage should use face.png four times."
+ );
+ /**
+ * We expect duplicate background-position values because we apply `right top`
+ * once for theme_frame, and again as the default value of
+ * --lwt-background-alignment.
+ */
+ Assert.equal(
+ toolboxCS.backgroundPosition,
+ "100% 0%, 100% 0%",
+ "The backgroundPosition should use the default value for navigator-toolbox."
+ );
+ Assert.equal(
+ toolboxCS.backgroundRepeat,
+ "no-repeat, repeat-x, repeat-y, repeat",
+ "The backgroundRepeat should use the three values provided for --lwt-background-tiling, preceeded by the default for theme_frame."
+ );
+ }
+
+ await extension.unload();
+
+ Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set");
+});
+
+add_task(async function test_additional_images_check() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "face.png",
+ },
+ colors: {
+ frame: FRAME_COLOR,
+ tab_background_text: TAB_BACKGROUND_TEXT_COLOR,
+ },
+ properties: {
+ additional_backgrounds_tiling: ["repeat-x", "repeat-y", "repeat"],
+ },
+ },
+ },
+ files: {
+ "face.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA),
+ },
+ });
+
+ await extension.startup();
+
+ let docEl = window.document.documentElement;
+ let toolbox = document.querySelector("#navigator-toolbox");
+
+ Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set");
+ Assert.equal(
+ docEl.getAttribute("lwtheme-brighttext"),
+ "true",
+ "LWT text color attribute should be set"
+ );
+
+ let toolboxCS = window.getComputedStyle(toolbox);
+ if (backgroundColorSetOnRoot()) {
+ let rootCS = window.getComputedStyle(docEl);
+ let bgImage = rootCS.backgroundImage.split(",")[0].trim();
+ Assert.ok(
+ bgImage.includes("face.png"),
+ `The backgroundImage should use face.png. Actual value is: ${bgImage}`
+ );
+ Assert.equal(
+ "none",
+ toolboxCS.backgroundImage,
+ "The backgroundImage should not use face.png."
+ );
+ Assert.equal(
+ rootCS.backgroundPosition,
+ "100% 0%, 100% 0%",
+ "The backgroundPosition should use the default value."
+ );
+ Assert.equal(
+ rootCS.backgroundRepeat,
+ "no-repeat, no-repeat",
+ "The backgroundPosition should use only one (default) value for the header and the default additional images."
+ );
+ } else {
+ let bgImage = toolboxCS.backgroundImage.split(",")[0].trim();
+ Assert.ok(
+ bgImage.includes("face.png"),
+ `The backgroundImage should use face.png. Actual value is: ${bgImage}`
+ );
+ Assert.ok(
+ bgImage.includes("face.png"),
+ `The backgroundImage should use face.png. Actual value is: ${bgImage}`
+ );
+ Assert.equal(
+ toolboxCS.backgroundPosition,
+ "100% 0%, 100% 0%",
+ "The backgroundPosition should use the default value."
+ );
+ Assert.equal(
+ toolboxCS.backgroundRepeat,
+ "no-repeat, no-repeat",
+ "The backgroundRepeat should use the default value."
+ );
+ }
+
+ await extension.unload();
+
+ Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set");
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js
new file mode 100644
index 0000000000..8e2f5446c9
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js
@@ -0,0 +1,203 @@
+"use strict";
+// This test checks whether the new tab page color properties work.
+
+function waitForAboutNewTabReady(browser, url) {
+ // Stop-gap fix for https://bugzilla.mozilla.org/show_bug.cgi?id=1697196#c24
+ return SpecialPowers.spawn(browser, [url], async url => {
+ let doc = content.document;
+ await ContentTaskUtils.waitForCondition(
+ () => doc.querySelector(".outer-wrapper"),
+ `Waiting for page wrapper to be initialized at ${url}`
+ );
+ });
+}
+
+/**
+ * Test whether the selected browser has the new tab page theme applied
+ *
+ * @param {object} theme that is applied
+ * @param {boolean} isBrightText whether the brighttext attribute should be set
+ */
+async function test_ntp_theme(theme, isBrightText) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme,
+ },
+ });
+
+ let browser = gBrowser.selectedBrowser;
+
+ let { originalBackground, originalCardBackground, originalColor } =
+ await SpecialPowers.spawn(browser, [], function () {
+ let doc = content.document;
+ ok(
+ !doc.documentElement.hasAttribute("lwt-newtab"),
+ "New tab page should not have lwt-newtab attribute"
+ );
+ ok(
+ !doc.documentElement.hasAttribute("lwt-newtab-brighttext"),
+ `New tab page should not have lwt-newtab-brighttext attribute`
+ );
+
+ return {
+ originalBackground: content.getComputedStyle(doc.body).backgroundColor,
+ originalCardBackground: content.getComputedStyle(
+ doc.querySelector(".top-site-outer .tile")
+ ).backgroundColor,
+ originalColor: content.getComputedStyle(
+ doc.querySelector(".outer-wrapper")
+ ).color,
+ // We check the value of --newtab-link-primary-color directly because the
+ // elements on which it is applied are hard to test. It is most visible in
+ // the "learn more" link in the Pocket section. We cannot show the Pocket
+ // section since it hits the network, and the usual workarounds to change
+ // its backend only work in browser/. This variable is also used in
+ // the Edit Top Site modal, but showing/hiding that is very verbose and
+ // would make this test almost unreadable.
+ originalLinks: content
+ .getComputedStyle(doc.documentElement)
+ .getPropertyValue("--newtab-link-primary-color"),
+ };
+ });
+
+ await extension.startup();
+
+ Services.ppmm.sharedData.flush();
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ isBrightText,
+ background: hexToCSS(theme.colors.ntp_background),
+ card_background: hexToCSS(theme.colors.ntp_card_background),
+ color: hexToCSS(theme.colors.ntp_text),
+ },
+ ],
+ async function ({ isBrightText, background, card_background, color }) {
+ let doc = content.document;
+ ok(
+ doc.documentElement.hasAttribute("lwt-newtab"),
+ "New tab page should have lwt-newtab attribute"
+ );
+ is(
+ doc.documentElement.hasAttribute("lwt-newtab-brighttext"),
+ isBrightText,
+ `New tab page should${
+ !isBrightText ? " not" : ""
+ } have lwt-newtab-brighttext attribute`
+ );
+
+ is(
+ content.getComputedStyle(doc.body).backgroundColor,
+ background,
+ "New tab page background should be set."
+ );
+ is(
+ content.getComputedStyle(doc.querySelector(".top-site-outer .tile"))
+ .backgroundColor,
+ card_background,
+ "New tab page card background should be set."
+ );
+ is(
+ content.getComputedStyle(doc.querySelector(".outer-wrapper")).color,
+ color,
+ "New tab page text color should be set."
+ );
+ }
+ );
+
+ await extension.unload();
+
+ Services.ppmm.sharedData.flush();
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ originalBackground,
+ originalCardBackground,
+ originalColor,
+ },
+ ],
+ function ({ originalBackground, originalCardBackground, originalColor }) {
+ let doc = content.document;
+ ok(
+ !doc.documentElement.hasAttribute("lwt-newtab"),
+ "New tab page should not have lwt-newtab attribute"
+ );
+ ok(
+ !doc.documentElement.hasAttribute("lwt-newtab-brighttext"),
+ `New tab page should not have lwt-newtab-brighttext attribute`
+ );
+
+ is(
+ content.getComputedStyle(doc.body).backgroundColor,
+ originalBackground,
+ "New tab page background should be reset."
+ );
+ is(
+ content.getComputedStyle(doc.querySelector(".top-site-outer .tile"))
+ .backgroundColor,
+ originalCardBackground,
+ "New tab page card background should be reset."
+ );
+ is(
+ content.getComputedStyle(doc.querySelector(".outer-wrapper")).color,
+ originalColor,
+ "New tab page text color should be reset."
+ );
+ }
+ );
+}
+
+add_task(async function test_support_ntp_colors() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // BrowserTestUtils.withNewTab waits for about:newtab to load
+ // so we disable preloading before running the test.
+ ["browser.newtab.preload", false],
+ // Force prefers-color-scheme to "light", as otherwise it might be
+ // derived from the theme, but we hard-code the light styles on this
+ // test.
+ ["layout.css.prefers-color-scheme.content-override", 1],
+ // Override the system color scheme to light so this test passes on
+ // machines with dark system color scheme.
+ ["ui.systemUsesDarkTheme", 0],
+ ],
+ });
+ NewTabPagePreloading.removePreloadedBrowser(window);
+ for (let url of ["about:newtab", "about:home"]) {
+ info("Opening url: " + url);
+ await BrowserTestUtils.withNewTab({ gBrowser, url }, async browser => {
+ await waitForAboutNewTabReady(browser, url);
+ await test_ntp_theme(
+ {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ ntp_background: "#add8e6",
+ ntp_card_background: "#ffffff",
+ ntp_text: "#00008b",
+ },
+ },
+ false,
+ url
+ );
+
+ await test_ntp_theme(
+ {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ ntp_background: "#00008b",
+ ntp_card_background: "#000000",
+ ntp_text: "#add8e6",
+ },
+ },
+ true,
+ url
+ );
+ });
+ }
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js
new file mode 100644
index 0000000000..9d28cf50c8
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js
@@ -0,0 +1,240 @@
+"use strict";
+
+// This test checks whether the new tab page color properties work per-window.
+
+function waitForAboutNewTabReady(browser, url) {
+ // Stop-gap fix for https://bugzilla.mozilla.org/show_bug.cgi?id=1697196#c24
+ return SpecialPowers.spawn(browser, [url], async url => {
+ let doc = content.document;
+ await ContentTaskUtils.waitForCondition(
+ () => doc.querySelector(".outer-wrapper"),
+ `Waiting for page wrapper to be initialized at ${url}`
+ );
+ });
+}
+
+/**
+ * Test whether a given browser has the new tab page theme applied
+ *
+ * @param {object} browser to test against
+ * @param {object} theme that is applied
+ * @param {boolean} isBrightText whether the brighttext attribute should be set
+ * @returns {Promise} The task as a promise
+ */
+function test_ntp_theme(browser, theme, isBrightText) {
+ Services.ppmm.sharedData.flush();
+ return SpecialPowers.spawn(
+ browser,
+ [
+ {
+ isBrightText,
+ background: hexToCSS(theme.colors.ntp_background),
+ card_background: hexToCSS(theme.colors.ntp_card_background),
+ color: hexToCSS(theme.colors.ntp_text),
+ },
+ ],
+ function ({ isBrightText, background, card_background, color }) {
+ let doc = content.document;
+ ok(
+ doc.documentElement.hasAttribute("lwt-newtab"),
+ "New tab page should have lwt-newtab attribute"
+ );
+ is(
+ doc.documentElement.hasAttribute("lwt-newtab-brighttext"),
+ isBrightText,
+ `New tab page should${
+ !isBrightText ? " not" : ""
+ } have lwt-newtab-brighttext attribute`
+ );
+
+ is(
+ content.getComputedStyle(doc.body).backgroundColor,
+ background,
+ "New tab page background should be set."
+ );
+ is(
+ content.getComputedStyle(doc.querySelector(".top-site-outer .tile"))
+ .backgroundColor,
+ card_background,
+ "New tab page card background should be set."
+ );
+ is(
+ content.getComputedStyle(doc.querySelector(".outer-wrapper")).color,
+ color,
+ "New tab page text color should be set."
+ );
+ }
+ );
+}
+
+/**
+ * Test whether a given browser has the default theme applied
+ *
+ * @param {object} browser to test against
+ * @param {string} url being tested
+ * @returns {Promise} The task as a promise
+ */
+function test_ntp_default_theme(browser, url) {
+ Services.ppmm.sharedData.flush();
+ return SpecialPowers.spawn(
+ browser,
+ [
+ {
+ background: hexToCSS("#F9F9FB"),
+ color: hexToCSS("#15141A"),
+ },
+ ],
+ function ({ background, color }) {
+ let doc = content.document;
+ ok(
+ !doc.documentElement.hasAttribute("lwt-newtab"),
+ "New tab page should not have lwt-newtab attribute"
+ );
+ ok(
+ !doc.documentElement.hasAttribute("lwt-newtab-brighttext"),
+ `New tab page should not have lwt-newtab-brighttext attribute`
+ );
+
+ is(
+ content.getComputedStyle(doc.body).backgroundColor,
+ background,
+ "New tab page background should be reset."
+ );
+ is(
+ content.getComputedStyle(doc.querySelector(".outer-wrapper")).color,
+ color,
+ "New tab page text color should be reset."
+ );
+ }
+ );
+}
+
+add_task(async function test_per_window_ntp_theme() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["theme"],
+ },
+ async background() {
+ function promiseWindowChecked() {
+ return new Promise(resolve => {
+ let listener = msg => {
+ if (msg == "checked-window") {
+ browser.test.onMessage.removeListener(listener);
+ resolve();
+ }
+ };
+ browser.test.onMessage.addListener(listener);
+ });
+ }
+
+ function removeWindow(winId) {
+ return new Promise(resolve => {
+ let listener = removedWinId => {
+ if (removedWinId == winId) {
+ browser.windows.onRemoved.removeListener(listener);
+ resolve();
+ }
+ };
+ browser.windows.onRemoved.addListener(listener);
+ browser.windows.remove(winId);
+ });
+ }
+
+ async function checkWindow(theme, isBrightText, winId) {
+ let windowChecked = promiseWindowChecked();
+ browser.test.sendMessage("check-window", {
+ theme,
+ isBrightText,
+ winId,
+ });
+ await windowChecked;
+ }
+
+ const darkTextTheme = {
+ colors: {
+ frame: "#add8e6",
+ tab_background_text: "#000",
+ ntp_background: "#add8e6",
+ ntp_card_background: "#ff0000",
+ ntp_text: "#000",
+ },
+ };
+
+ const brightTextTheme = {
+ colors: {
+ frame: "#00008b",
+ tab_background_text: "#add8e6",
+ ntp_background: "#00008b",
+ ntp_card_background: "#00ff00",
+ ntp_text: "#add8e6",
+ },
+ };
+
+ let { id: winId } = await browser.windows.getCurrent();
+ // We are opening about:blank instead of the default homepage,
+ // because using the default homepage results in intermittent
+ // test failures on debug builds due to browser window leaks.
+ // A side effect of testing on about:blank is that
+ // test_ntp_default_theme cannot test properties used only on
+ // about:newtab, like ntp_card_background.
+ let { id: secondWinId } = await browser.windows.create({
+ url: "about:blank",
+ });
+
+ browser.test.log("Test that single window update works");
+ await browser.theme.update(winId, darkTextTheme);
+ await checkWindow(darkTextTheme, false, winId);
+ await checkWindow(null, false, secondWinId);
+
+ browser.test.log("Test that applying different themes on both windows");
+ await browser.theme.update(secondWinId, brightTextTheme);
+ await checkWindow(darkTextTheme, false, winId);
+ await checkWindow(brightTextTheme, true, secondWinId);
+
+ browser.test.log("Test resetting the theme on one window");
+ await browser.theme.reset(winId);
+ await checkWindow(null, false, winId);
+ await checkWindow(brightTextTheme, true, secondWinId);
+
+ await removeWindow(secondWinId);
+ await checkWindow(null, false, winId);
+ browser.test.notifyPass("perwindow-ntp-theme");
+ },
+ });
+
+ extension.onMessage(
+ "check-window",
+ async ({ theme, isBrightText, winId }) => {
+ let win = Services.wm.getOuterWindowWithId(winId);
+ win.NewTabPagePreloading.removePreloadedBrowser(win);
+ // These pages were initially chosen because LightweightThemeChild.sys.mjs
+ // treats them specially.
+ for (let url of ["about:newtab", "about:home"]) {
+ info("Opening url: " + url);
+ await BrowserTestUtils.withNewTab(
+ { gBrowser: win.gBrowser, url },
+ async browser => {
+ await waitForAboutNewTabReady(browser, url);
+ if (theme) {
+ await test_ntp_theme(browser, theme, isBrightText);
+ } else {
+ await test_ntp_default_theme(browser, url);
+ }
+ }
+ );
+ }
+ extension.sendMessage("checked-window");
+ }
+ );
+
+ // BrowserTestUtils.withNewTab waits for about:newtab to load
+ // so we disable preloading before running the test.
+ await SpecialPowers.setBoolPref("browser.newtab.preload", false);
+ registerCleanupFunction(() => {
+ SpecialPowers.clearUserPref("browser.newtab.preload");
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("perwindow-ntp-theme");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_pbm.js b/toolkit/components/extensions/test/browser/browser_ext_themes_pbm.js
new file mode 100644
index 0000000000..f830e55294
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_pbm.js
@@ -0,0 +1,439 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/**
+ * Tests that we apply dark theme variants to PBM windows where applicable.
+ */
+
+const { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+);
+const { PromptTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromptTestUtils.sys.mjs"
+);
+
+const IS_LINUX = AppConstants.platform == "linux";
+
+const LIGHT_THEME_ID = "firefox-compact-light@mozilla.org";
+const DARK_THEME_ID = "firefox-compact-dark@mozilla.org";
+
+// This tests opens many chrome windows which is slow on debug builds.
+requestLongerTimeout(2);
+
+/**
+ * Test a window's theme color scheme.
+ *
+ * @param {*} options - Test options.
+ * @param {Window} options.win - Window object to test.
+ * @param {boolean} options.colorScheme - Whether expected chrome color scheme
+ * is dark (true) or light (false).
+ * @param {boolean} options.expectLWTAttributes - Whether the window should
+ * have the LWT attributes set matching the color scheme.
+ * @param {boolean} options.expectDefaultDarkAttribute - Whether the window
+ * should have the "lwt-default-theme-in-dark-mode" attribute.
+ */
+async function testWindowColorScheme({
+ win,
+ expectDark,
+ expectLWTAttributes,
+ expectDefaultDarkAttribute,
+}) {
+ let docEl = win.document.documentElement;
+
+ is(
+ docEl.hasAttribute("lwt-default-theme-in-dark-mode"),
+ expectDefaultDarkAttribute,
+ `Window should${
+ expectDefaultDarkAttribute ? "" : " not"
+ } have lwt-default-theme-in-dark-mode attribute.`
+ );
+
+ if (expectLWTAttributes) {
+ ok(docEl.hasAttribute("lwtheme"), "Window should have LWT attribute.");
+ is(
+ docEl.getAttribute("lwtheme-brighttext"),
+ expectDark ? "true" : null,
+ "LWT text color attribute should be set."
+ );
+ } else {
+ ok(!docEl.hasAttribute("lwtheme"), "Window should not have LWT attribute.");
+ ok(
+ !docEl.hasAttribute("lwtheme-brighttext"),
+ "LWT text color attribute should not be set."
+ );
+ }
+}
+
+/**
+ * Match the prefers-color-scheme media query and return the results.
+ *
+ * @param {object} options
+ * @param {Window} options.win - If chrome=true, window to test, otherwise
+ * parent window of the content window to test.
+ * @param {boolean} options.chrome - If true the media queries will be matched
+ * against the supplied chrome window. Otherwise they will be matched against
+ * the content window.
+ * @returns {Promise<{light: boolean, dark: boolean}>} - Resolves with an
+ * object of the media query results.
+ */
+function getPrefersColorSchemeInfo({ win, chrome = false }) {
+ let fn = async windowObj => {
+ // If called in the parent, we use the supplied win object. Otherwise use
+ // the content window global.
+ let win = windowObj || content;
+
+ // LookAndFeel updates are async.
+ await new Promise(resolve => {
+ win.requestAnimationFrame(() => win.requestAnimationFrame(resolve));
+ });
+ return {
+ light: win.matchMedia("(prefers-color-scheme: light)").matches,
+ dark: win.matchMedia("(prefers-color-scheme: dark)").matches,
+ };
+ };
+
+ if (chrome) {
+ return fn(win);
+ }
+
+ return SpecialPowers.spawn(win.gBrowser.selectedBrowser, [], fn);
+}
+
+add_setup(async function () {
+ // Set system theme to light to ensure consistency across test machines.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.theme.dark-private-windows", true],
+ ["ui.systemUsesDarkTheme", 0],
+ ],
+ });
+ // Ensure the built-in themes are initialized.
+ await BuiltInThemes.ensureBuiltInThemes();
+
+ // The previous test, browser_ext_themes_ntp_colors.js has side effects.
+ // Switch to a theme, then switch back to the default theme to reach a
+ // consistent themeData state. Without this, themeData in
+ // LightWeightConsumer#_update does not contain darkTheme data and PBM windows
+ // don't get themed correctly.
+ let lightTheme = await AddonManager.getAddonByID(LIGHT_THEME_ID);
+ await lightTheme.enable();
+ await lightTheme.disable();
+});
+
+// For the default theme with light color scheme, private browsing windows
+// should be themed dark.
+// The PBM window's content should not be themed dark.
+add_task(async function test_default_theme_light() {
+ info("Normal browsing window should not be in dark mode.");
+ await testWindowColorScheme({
+ win: window,
+ expectDark: false,
+ expectLWTAttributes: false,
+ expectDefaultDarkAttribute: false,
+ });
+
+ let windowB = await BrowserTestUtils.openNewBrowserWindow();
+
+ info("Additional normal browsing window should not be in dark mode.");
+ await testWindowColorScheme({
+ win: windowB,
+ expectDark: false,
+ expectLWTAttributes: false,
+ expectDefaultDarkAttribute: false,
+ });
+
+ let pbmWindowA = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ info("Private browsing window should be in dark mode.");
+ await testWindowColorScheme({
+ win: pbmWindowA,
+ expectDark: true,
+ expectLWTAttributes: true,
+ expectDefaultDarkAttribute: true,
+ });
+
+ let prefersColorScheme = await getPrefersColorSchemeInfo({ win: pbmWindowA });
+ ok(
+ prefersColorScheme.light && !prefersColorScheme.dark,
+ "Content of dark themed PBM window should still be themed light"
+ );
+
+ let pbmWindowB = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ info("Additional private browsing window should be in dark mode.");
+ await testWindowColorScheme({
+ win: pbmWindowB,
+ expectDark: true,
+ expectLWTAttributes: true,
+ expectDefaultDarkAttribute: true,
+ });
+
+ await BrowserTestUtils.closeWindow(windowB);
+ await BrowserTestUtils.closeWindow(pbmWindowA);
+ await BrowserTestUtils.closeWindow(pbmWindowB);
+});
+
+// For the default theme with dark color scheme, normal and private browsing
+// windows should be themed dark.
+add_task(async function test_default_theme_dark() {
+ // Set the system theme to dark. The default theme will follow this color
+ // scheme.
+ await SpecialPowers.pushPrefEnv({ set: [["ui.systemUsesDarkTheme", 1]] });
+
+ info("Normal browsing window should be in dark mode.");
+ await testWindowColorScheme({
+ win: window,
+ expectDark: true,
+ expectLWTAttributes: !IS_LINUX,
+ expectDefaultDarkAttribute: !IS_LINUX,
+ });
+
+ let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ info("Private browsing window should be in dark mode.");
+ await testWindowColorScheme({
+ win: pbmWindow,
+ expectDark: true,
+ expectLWTAttributes: !IS_LINUX,
+ expectDefaultDarkAttribute: !IS_LINUX,
+ });
+
+ await BrowserTestUtils.closeWindow(pbmWindow);
+
+ await SpecialPowers.popPrefEnv();
+});
+
+// For the light theme both normal and private browsing windows should have a
+// bright color scheme applied.
+add_task(async function test_light_theme_builtin() {
+ let lightTheme = await AddonManager.getAddonByID(LIGHT_THEME_ID);
+ await lightTheme.enable();
+
+ info("Normal browsing window should not be in dark mode.");
+ await testWindowColorScheme({
+ win: window,
+ expectDark: false,
+ expectLWTAttributes: true,
+ expectDefaultDarkAttribute: false,
+ });
+
+ let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ info("Private browsing window should not be in dark mode.");
+ await testWindowColorScheme({
+ win: pbmWindow,
+ expectDark: false,
+ expectLWTAttributes: true,
+ expectDefaultDarkAttribute: false,
+ });
+
+ await BrowserTestUtils.closeWindow(pbmWindow);
+ await lightTheme.disable();
+});
+
+// For the dark theme both normal and private browsing should have a dark color
+// scheme applied.
+add_task(async function test_dark_theme_builtin() {
+ let darkTheme = await AddonManager.getAddonByID(DARK_THEME_ID);
+ await darkTheme.enable();
+
+ info("Normal browsing window should be in dark mode.");
+ await testWindowColorScheme({
+ win: window,
+ expectDark: true,
+ expectLWTAttributes: true,
+ expectDefaultDarkAttribute: false,
+ });
+
+ let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ info("Private browsing window should be in dark mode.");
+ await testWindowColorScheme({
+ win: pbmWindow,
+ expectDark: true,
+ expectLWTAttributes: true,
+ expectDefaultDarkAttribute: false,
+ });
+
+ await BrowserTestUtils.closeWindow(pbmWindow);
+ await darkTheme.disable();
+});
+
+// When switching between default, light and dark theme the private browsing
+// window color scheme should update accordingly.
+add_task(async function test_theme_switch_updates_existing_pbm_win() {
+ let windowB = await BrowserTestUtils.openNewBrowserWindow();
+ let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ info("Normal browsing window should not be in dark mode.");
+ await testWindowColorScheme({
+ win: window,
+ expectDark: false,
+ expectLWTAttributes: false,
+ expectDefaultDarkAttribute: false,
+ });
+
+ info("Private browsing window should be in dark mode.");
+ await testWindowColorScheme({
+ win: pbmWindow,
+ expectDark: true,
+ expectLWTAttributes: true,
+ expectDefaultDarkAttribute: true,
+ });
+
+ info("Enabling light theme.");
+ let lightTheme = await AddonManager.getAddonByID(LIGHT_THEME_ID);
+ await lightTheme.enable();
+
+ info("Normal browsing window should not be in dark mode.");
+ await testWindowColorScheme({
+ win: window,
+ expectDark: false,
+ expectLWTAttributes: true,
+ expectDefaultDarkAttribute: false,
+ });
+
+ info("Private browsing window should not be in dark mode.");
+ await testWindowColorScheme({
+ win: pbmWindow,
+ expectDark: false,
+ expectLWTAttributes: true,
+ expectDefaultDarkAttribute: false,
+ });
+
+ await lightTheme.disable();
+
+ info("Enabling dark theme.");
+ let darkTheme = await AddonManager.getAddonByID(DARK_THEME_ID);
+ await darkTheme.enable();
+
+ info("Normal browsing window should be in dark mode.");
+ await testWindowColorScheme({
+ win: window,
+ expectDark: true,
+ expectLWTAttributes: true,
+ expectDefaultDarkAttribute: false,
+ });
+
+ info("Private browsing window should be in dark mode.");
+ await testWindowColorScheme({
+ win: pbmWindow,
+ expectDark: true,
+ expectLWTAttributes: true,
+ expectDefaultDarkAttribute: false,
+ });
+
+ await darkTheme.disable();
+
+ await BrowserTestUtils.closeWindow(windowB);
+ await BrowserTestUtils.closeWindow(pbmWindow);
+});
+
+// pageInfo windows should inherit the PBM window dark theme.
+add_task(async function test_pbm_dark_page_info() {
+ for (let isPBM of [false, true]) {
+ let win = await BrowserTestUtils.openNewBrowserWindow({
+ private: isPBM,
+ });
+ let windowTypeStr = isPBM ? "private" : "normal";
+
+ info(`Opening pageInfo from ${windowTypeStr} browsing.`);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser: win.gBrowser, url: "https://example.com" },
+ async () => {
+ let pageInfo = win.BrowserPageInfo(null, "securityTab");
+ await BrowserTestUtils.waitForEvent(pageInfo, "page-info-init");
+
+ let prefersColorScheme = await getPrefersColorSchemeInfo({
+ win: pageInfo,
+ chrome: true,
+ });
+ if (isPBM) {
+ ok(
+ !prefersColorScheme.light && prefersColorScheme.dark,
+ "pageInfo from private window should be themed dark."
+ );
+ } else {
+ ok(
+ prefersColorScheme.light && !prefersColorScheme.dark,
+ "pageInfo from normal window should be themed light."
+ );
+ }
+
+ pageInfo.close();
+ }
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ }
+});
+
+// Prompts should inherit the PBM window dark theme.
+add_task(async function test_pbm_dark_prompts() {
+ const { MODAL_TYPE_TAB, MODAL_TYPE_CONTENT } = Services.prompt;
+
+ for (let isPBM of [false, true]) {
+ let win = await BrowserTestUtils.openNewBrowserWindow({
+ private: isPBM,
+ });
+
+ // TODO: Once Bug 1751953 has been fixed, we can also test MODAL_TYPE_WINDOW
+ // here.
+ for (let modalType of [MODAL_TYPE_TAB, MODAL_TYPE_CONTENT]) {
+ let windowTypeStr = isPBM ? "private" : "normal";
+ let modalTypeStr = modalType == MODAL_TYPE_TAB ? "tab" : "content";
+
+ info(`Opening ${modalTypeStr} prompt from ${windowTypeStr} browsing.`);
+
+ let openPromise = PromptTestUtils.waitForPrompt(
+ win.gBrowser.selectedBrowser,
+ {
+ modalType,
+ promptType: "alert",
+ }
+ );
+ let promptPromise = Services.prompt.asyncAlert(
+ win.gBrowser.selectedBrowser.browsingContext,
+ modalType,
+ "Hello",
+ "Hello, world!"
+ );
+
+ let dialog = await openPromise;
+
+ let prefersColorScheme = await getPrefersColorSchemeInfo({
+ win: dialog.ui.prompt,
+ chrome: true,
+ });
+
+ if (isPBM) {
+ ok(
+ !prefersColorScheme.light && prefersColorScheme.dark,
+ "Prompt from private window should be themed dark."
+ );
+ } else {
+ ok(
+ prefersColorScheme.light && !prefersColorScheme.dark,
+ "Prompt from normal window should be themed light."
+ );
+ }
+
+ await PromptTestUtils.handlePrompt(dialog);
+ await promptPromise;
+ }
+
+ await BrowserTestUtils.closeWindow(win);
+ }
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js b/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js
new file mode 100644
index 0000000000..526c3a0883
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js
@@ -0,0 +1,64 @@
+"use strict";
+
+// This test checks whether applied WebExtension themes are persisted and applied
+// on newly opened windows.
+
+add_task(async function test_multiple_windows() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ let docEl = window.document.documentElement;
+ let toolbox = document.querySelector("#navigator-toolbox");
+ let computedStyle = window.getComputedStyle(
+ backgroundColorSetOnRoot() ? docEl : toolbox
+ );
+
+ Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set");
+ Assert.equal(
+ docEl.getAttribute("lwtheme-brighttext"),
+ "true",
+ "LWT text color attribute should be set"
+ );
+ Assert.ok(
+ computedStyle.backgroundImage.includes("image1.png"),
+ "Expected background image"
+ );
+
+ // Now we'll open a new window to see if the theme is also applied there.
+ let window2 = await BrowserTestUtils.openNewBrowserWindow();
+ docEl = window2.document.documentElement;
+ toolbox = window2.document.querySelector("#navigator-toolbox");
+ computedStyle = window.getComputedStyle(
+ backgroundColorSetOnRoot() ? docEl : toolbox
+ );
+
+ Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set");
+ Assert.equal(
+ docEl.getAttribute("lwtheme-brighttext"),
+ "true",
+ "LWT text color attribute should be set"
+ );
+ Assert.ok(
+ computedStyle.backgroundImage.includes("image1.png"),
+ "Expected background image"
+ );
+
+ await BrowserTestUtils.closeWindow(window2);
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_reset.js b/toolkit/components/extensions/test/browser/browser_ext_themes_reset.js
new file mode 100644
index 0000000000..d8b3b14073
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_reset.js
@@ -0,0 +1,112 @@
+"use strict";
+
+add_task(async function theme_reset_global_static_theme() {
+ let global_theme_extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: "#123456",
+ tab_background_text: "#fedcba",
+ },
+ },
+ },
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["theme"],
+ },
+ async background() {
+ await browser.theme.reset();
+ let theme_after_reset = await browser.theme.getCurrent();
+
+ browser.test.assertEq(
+ "#123456",
+ theme_after_reset.colors.frame,
+ "Theme from other extension should not be cleared upon reset()"
+ );
+
+ let theme = {
+ colors: {
+ frame: "#CF723F",
+ },
+ };
+
+ await browser.theme.update(theme);
+ await browser.theme.reset();
+ let final_reset_theme = await browser.theme.getCurrent();
+
+ browser.test.assertEq(
+ JSON.stringify({ colors: null, images: null, properties: null }),
+ JSON.stringify(final_reset_theme),
+ "Should reset when extension had replaced the global theme"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+ await global_theme_extension.startup();
+ await extension.startup();
+ await extension.awaitMessage("done");
+
+ await global_theme_extension.unload();
+ await extension.unload();
+});
+
+add_task(async function theme_reset_by_windowId() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["theme"],
+ },
+ async background() {
+ let theme = {
+ colors: {
+ frame: "#CF723F",
+ },
+ };
+
+ let { id: winId } = await browser.windows.getCurrent();
+ await browser.theme.update(winId, theme);
+ let update_theme = await browser.theme.getCurrent(winId);
+
+ browser.test.onMessage.addListener(async () => {
+ let current_theme = await browser.theme.getCurrent(winId);
+ browser.test.assertEq(
+ update_theme.colors.frame,
+ current_theme.colors.frame,
+ "Should not be reset by a reset(windowId) call from another extension"
+ );
+
+ browser.test.sendMessage("done");
+ });
+
+ browser.test.sendMessage("ready", winId);
+ },
+ });
+
+ let anotherExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["theme"],
+ },
+ background() {
+ browser.test.onMessage.addListener(async winId => {
+ await browser.theme.reset(winId);
+ browser.test.sendMessage("done");
+ });
+ },
+ });
+
+ await extension.startup();
+ let winId = await extension.awaitMessage("ready");
+
+ await anotherExtension.startup();
+
+ // theme.reset should be ignored if the theme was set by another extension.
+ anotherExtension.sendMessage(winId);
+ await anotherExtension.awaitMessage("done");
+
+ extension.sendMessage();
+ await extension.awaitMessage("done");
+
+ await anotherExtension.unload();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js b/toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js
new file mode 100644
index 0000000000..2e7359ce29
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js
@@ -0,0 +1,187 @@
+"use strict";
+
+// This test checks color sanitization in various situations
+
+add_task(async function test_sanitization_invalid() {
+ // This test checks that invalid values are sanitized
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ bookmark_text: "ntimsfavoriteblue",
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let navbar = document.querySelector("#nav-bar");
+ Assert.equal(
+ window.getComputedStyle(navbar).color,
+ "rgb(0, 0, 0)",
+ "All invalid values should always compute to black."
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_sanitization_css_variables() {
+ // This test checks that CSS variables are sanitized
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ bookmark_text: "var(--arrowpanel-dimmed)",
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let navbar = document.querySelector("#nav-bar");
+ Assert.equal(
+ window.getComputedStyle(navbar).color,
+ "rgb(0, 0, 0)",
+ "All CSS variables should always compute to black."
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_sanitization_important() {
+ // This test checks that the sanitizer cannot be fooled with !important
+ let stylesheetAttr = `href="data:text/css,*{color:red!important}" type="text/css"`;
+ let stylesheet = document.createProcessingInstruction(
+ "xml-stylesheet",
+ stylesheetAttr
+ );
+ let load = BrowserTestUtils.waitForEvent(stylesheet, "load");
+ document.insertBefore(stylesheet, document.documentElement);
+ await load;
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ bookmark_text: "green",
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let navbar = document.querySelector("#nav-bar");
+ Assert.equal(
+ window.getComputedStyle(navbar).color,
+ "rgb(255, 0, 0)",
+ "Sheet applies"
+ );
+
+ stylesheet.remove();
+
+ Assert.equal(
+ window.getComputedStyle(navbar).color,
+ "rgb(0, 128, 0)",
+ "Shouldn't be able to fool the color sanitizer with !important"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_sanitization_transparent() {
+ // This test checks whether transparent values are applied properly
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ toolbar_top_separator: "transparent",
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let navbar = document.querySelector("#nav-bar");
+ Assert.ok(
+ window.getComputedStyle(navbar).boxShadow.includes("rgba(0, 0, 0, 0)"),
+ "Top separator should be transparent"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_sanitization_transparent_frame_color() {
+ // This test checks whether transparent frame color falls back to white.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: "transparent",
+ tab_background_text: TEXT_COLOR,
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let toolbox = document.querySelector("#navigator-toolbox");
+ let toolboxCS = window.getComputedStyle(toolbox);
+
+ if (backgroundColorSetOnRoot()) {
+ let docEl = document.documentElement;
+ let rootCS = window.getComputedStyle(docEl);
+ Assert.equal(
+ rootCS.backgroundColor,
+ "rgb(255, 255, 255)",
+ "Accent color should be white"
+ );
+ } else {
+ Assert.equal(
+ toolboxCS.backgroundColor,
+ "rgb(255, 255, 255)",
+ "Accent color should be white"
+ );
+ }
+
+ await extension.unload();
+});
+
+add_task(
+ async function test_sanitization_transparent_tab_background_text_color() {
+ // This test checks whether transparent textcolor falls back to black.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: "transparent",
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let docEl = document.documentElement;
+ Assert.equal(
+ window.getComputedStyle(docEl).color,
+ "rgb(0, 0, 0)",
+ "Text color should be black"
+ );
+
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_separators.js b/toolkit/components/extensions/test/browser/browser_ext_themes_separators.js
new file mode 100644
index 0000000000..4da4927ccf
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_separators.js
@@ -0,0 +1,76 @@
+"use strict";
+
+// This test checks whether applied WebExtension themes that attempt to change
+// the separator colors are applied properly.
+
+add_task(async function test_support_separator_properties() {
+ const SEPARATOR_TOP_COLOR = "#ff00ff";
+ const SEPARATOR_VERTICAL_COLOR = "#f0000f";
+ const SEPARATOR_FIELD_COLOR = "#9400ff";
+ const SEPARATOR_BOTTOM_COLOR = "#3366cc";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ toolbar_top_separator: SEPARATOR_TOP_COLOR,
+ toolbar_vertical_separator: SEPARATOR_VERTICAL_COLOR,
+ // This property is deprecated, but left in to check it doesn't
+ // unexpectedly break the theme installation.
+ toolbar_field_separator: SEPARATOR_FIELD_COLOR,
+ toolbar_bottom_separator: SEPARATOR_BOTTOM_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ // Test the deprecated color property.
+ let deprecatedMessagePromise = new Promise(resolve => {
+ Services.console.registerListener(function listener(msg) {
+ if (msg.message.includes("toolbar_field_separator")) {
+ resolve();
+ Services.console.unregisterListener(listener);
+ }
+ });
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ info("Wait for property deprecation message");
+ await deprecatedMessagePromise;
+
+ let navbar = document.querySelector("#nav-bar");
+ Assert.ok(
+ window
+ .getComputedStyle(navbar)
+ .boxShadow.includes(`rgb(${hexToRGB(SEPARATOR_TOP_COLOR).join(", ")})`),
+ "Top separator color properly set"
+ );
+
+ let panelUIButton = document.querySelector("#PanelUI-button");
+ // Bug 1712334: This should test bookmark item toolbar separators instead
+ Assert.equal(
+ window
+ .getComputedStyle(panelUIButton)
+ .getPropertyValue("border-image-source"),
+ "none",
+ "No vertical separator on app menu"
+ );
+
+ let toolbox = document.querySelector("#navigator-toolbox");
+ Assert.equal(
+ window.getComputedStyle(toolbox).borderBottomColor,
+ `rgb(${hexToRGB(SEPARATOR_BOTTOM_COLOR).join(", ")})`,
+ "Bottom separator color properly set"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js b/toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js
new file mode 100644
index 0000000000..35a7955f9f
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js
@@ -0,0 +1,275 @@
+"use strict";
+
+// This test checks whether the sidebar color properties work.
+
+/**
+ * Test whether the selected browser has the sidebar theme applied
+ *
+ * @param {object} theme that is applied
+ * @param {boolean} isBrightText whether the brighttext attribute should be set
+ */
+async function test_sidebar_theme(theme, isBrightText) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme,
+ },
+ });
+
+ const sidebarBox = document.getElementById("sidebar-box");
+ const content = SidebarUI.browser.contentWindow;
+ const root = content.document.documentElement;
+
+ ok(
+ !sidebarBox.hasAttribute("lwt-sidebar"),
+ "Sidebar box should not have lwt-sidebar attribute"
+ );
+ ok(
+ !root.hasAttribute("lwt-sidebar"),
+ "Sidebar should not have lwt-sidebar attribute"
+ );
+ ok(
+ !root.hasAttribute("lwt-sidebar-brighttext"),
+ "Sidebar should not have lwt-sidebar-brighttext attribute"
+ );
+ ok(
+ !root.hasAttribute("lwt-sidebar-highlight"),
+ "Sidebar should not have lwt-sidebar-highlight attribute"
+ );
+
+ const rootCS = content.getComputedStyle(root);
+ const originalBackground = rootCS.backgroundColor;
+ const originalColor = rootCS.color;
+
+ // ::-moz-tree-row(selected, focus) computed style can't be accessed, so we create a fake one.
+ const highlightCS = {
+ get backgroundColor() {
+ // Standardize to rgb like other computed style.
+ let color = rootCS.getPropertyValue(
+ "--lwt-sidebar-highlight-background-color"
+ );
+ let [r, g, b] = color
+ .replace("rgba(", "")
+ .split(",")
+ .map(channel => parseInt(channel, 10));
+ return `rgb(${r}, ${g}, ${b})`;
+ },
+
+ get color() {
+ let color = rootCS.getPropertyValue("--lwt-sidebar-highlight-text-color");
+ let [r, g, b] = color
+ .replace("rgba(", "")
+ .split(",")
+ .map(channel => parseInt(channel, 10));
+ return `rgb(${r}, ${g}, ${b})`;
+ },
+ };
+ const originalHighlightBackground = highlightCS.backgroundColor;
+ const originalHighlightColor = highlightCS.color;
+
+ await extension.startup();
+
+ Services.ppmm.sharedData.flush();
+
+ const actualBackground = hexToCSS(theme.colors.sidebar) || originalBackground;
+ const actualColor = hexToCSS(theme.colors.sidebar_text) || originalColor;
+ const actualHighlightBackground =
+ hexToCSS(theme.colors.sidebar_highlight) || originalHighlightBackground;
+ const actualHighlightColor =
+ hexToCSS(theme.colors.sidebar_highlight_text) || originalHighlightColor;
+ const isCustomHighlight = !!theme.colors.sidebar_highlight_text;
+ const isCustomSidebar = !!theme.colors.sidebar_text;
+
+ is(
+ sidebarBox.hasAttribute("lwt-sidebar"),
+ isCustomSidebar,
+ `Sidebar box should${
+ !isCustomSidebar ? " not" : ""
+ } have lwt-sidebar attribute`
+ );
+ is(
+ root.hasAttribute("lwt-sidebar"),
+ isCustomSidebar,
+ `Sidebar should${!isCustomSidebar ? " not" : ""} have lwt-sidebar attribute`
+ );
+ is(
+ root.hasAttribute("lwt-sidebar-brighttext"),
+ isBrightText,
+ `Sidebar should${
+ !isBrightText ? " not" : ""
+ } have lwt-sidebar-brighttext attribute`
+ );
+ is(
+ root.hasAttribute("lwt-sidebar-highlight"),
+ isCustomHighlight,
+ `Sidebar should${
+ !isCustomHighlight ? " not" : ""
+ } have lwt-sidebar-highlight attribute`
+ );
+
+ if (isCustomSidebar) {
+ const sidebarBoxCS = window.getComputedStyle(sidebarBox);
+ is(
+ sidebarBoxCS.backgroundColor,
+ actualBackground,
+ "Sidebar box background should be set."
+ );
+ is(
+ sidebarBoxCS.color,
+ actualColor,
+ "Sidebar box text color should be set."
+ );
+ }
+
+ is(
+ rootCS.backgroundColor,
+ actualBackground,
+ "Sidebar background should be set."
+ );
+ is(rootCS.color, actualColor, "Sidebar text color should be set.");
+
+ is(
+ highlightCS.backgroundColor,
+ actualHighlightBackground,
+ "Sidebar highlight background color should be set."
+ );
+ is(
+ highlightCS.color,
+ actualHighlightColor,
+ "Sidebar highlight text color should be set."
+ );
+
+ await extension.unload();
+
+ Services.ppmm.sharedData.flush();
+
+ ok(
+ !sidebarBox.hasAttribute("lwt-sidebar"),
+ "Sidebar box should not have lwt-sidebar attribute"
+ );
+ ok(
+ !root.hasAttribute("lwt-sidebar"),
+ "Sidebar should not have lwt-sidebar attribute"
+ );
+ ok(
+ !root.hasAttribute("lwt-sidebar-brighttext"),
+ "Sidebar should not have lwt-sidebar-brighttext attribute"
+ );
+ ok(
+ !root.hasAttribute("lwt-sidebar-highlight"),
+ "Sidebar should not have lwt-sidebar-highlight attribute"
+ );
+
+ is(
+ rootCS.backgroundColor,
+ originalBackground,
+ "Sidebar background should be reset."
+ );
+ is(rootCS.color, originalColor, "Sidebar text color should be reset.");
+ is(
+ highlightCS.backgroundColor,
+ originalHighlightBackground,
+ "Sidebar highlight background color should be reset."
+ );
+ is(
+ highlightCS.color,
+ originalHighlightColor,
+ "Sidebar highlight text color should be reset."
+ );
+}
+
+add_task(async function test_support_sidebar_colors() {
+ for (let command of ["viewBookmarksSidebar", "viewHistorySidebar"]) {
+ info("Executing command: " + command);
+
+ await SidebarUI.show(command);
+
+ await test_sidebar_theme(
+ {
+ colors: {
+ sidebar: "#fafad2", // lightgoldenrodyellow
+ sidebar_text: "#2f4f4f", // darkslategrey
+ },
+ },
+ false
+ );
+
+ await test_sidebar_theme(
+ {
+ colors: {
+ sidebar: "#8b4513", // saddlebrown
+ sidebar_text: "#ffa07a", // lightsalmon
+ },
+ },
+ true
+ );
+
+ await test_sidebar_theme(
+ {
+ colors: {
+ sidebar: "#fffafa", // snow
+ sidebar_text: "#663399", // rebeccapurple
+ sidebar_highlight: "#7cfc00", // lawngreen
+ sidebar_highlight_text: "#ffefd5", // papayawhip
+ },
+ },
+ false
+ );
+
+ await test_sidebar_theme(
+ {
+ colors: {
+ sidebar_highlight: "#a0522d", // sienna
+ sidebar_highlight_text: "#fff5ee", // seashell
+ },
+ },
+ false
+ );
+ }
+});
+
+add_task(async function test_support_sidebar_border_color() {
+ const LIGHT_SALMON = "#ffa07a";
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ sidebar_border: LIGHT_SALMON,
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ const sidebarHeader = document.getElementById("sidebar-header");
+ const sidebarHeaderCS = window.getComputedStyle(sidebarHeader);
+
+ is(
+ sidebarHeaderCS.borderBottomColor,
+ hexToCSS(LIGHT_SALMON),
+ "Sidebar header border should be colored properly"
+ );
+
+ if (AppConstants.platform !== "linux") {
+ const sidebarSplitter = document.getElementById("sidebar-splitter");
+ const sidebarSplitterCS = window.getComputedStyle(sidebarSplitter);
+
+ is(
+ sidebarSplitterCS.borderInlineEndColor,
+ hexToCSS(LIGHT_SALMON),
+ "Sidebar splitter should be colored properly"
+ );
+
+ SidebarUI.reversePosition();
+
+ is(
+ sidebarSplitterCS.borderInlineStartColor,
+ hexToCSS(LIGHT_SALMON),
+ "Sidebar splitter should be colored properly after switching sides"
+ );
+
+ SidebarUI.reversePosition();
+ }
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js b/toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js
new file mode 100644
index 0000000000..4a6d9a92f6
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js
@@ -0,0 +1,126 @@
+"use strict";
+
+// This test checks whether browser.theme.onUpdated works
+// when a static theme is applied
+
+add_task(async function test_on_updated() {
+ const theme = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.theme.onUpdated.addListener(updateInfo => {
+ browser.test.sendMessage("theme-updated", updateInfo);
+ });
+ },
+ });
+
+ await extension.startup();
+
+ info("Testing update event on static theme startup");
+ let updatedPromise = extension.awaitMessage("theme-updated");
+ await theme.startup();
+ const { theme: receivedTheme, windowId } = await updatedPromise;
+ Assert.ok(!windowId, "No window id in static theme update event");
+ Assert.ok(
+ receivedTheme.images.theme_frame.includes("image1.png"),
+ "Theme theme_frame image should be applied"
+ );
+ Assert.equal(
+ receivedTheme.colors.frame,
+ ACCENT_COLOR,
+ "Theme frame color should be applied"
+ );
+ Assert.equal(
+ receivedTheme.colors.tab_background_text,
+ TEXT_COLOR,
+ "Theme tab_background_text color should be applied"
+ );
+
+ info("Testing update event on static theme unload");
+ updatedPromise = extension.awaitMessage("theme-updated");
+ await theme.unload();
+ const updateInfo = await updatedPromise;
+ Assert.ok(!windowId, "No window id in static theme update event on unload");
+ Assert.equal(
+ Object.keys(updateInfo.theme),
+ 0,
+ "unloading theme sends empty theme in update event"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_on_updated_eventpage() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.eventPages.enabled", true]],
+ });
+ const theme = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "watcher@mochitest" } },
+ background: { persistent: false },
+ },
+ background() {
+ browser.theme.onUpdated.addListener(updateInfo => {
+ browser.test.sendMessage("theme-updated", updateInfo);
+ });
+ },
+ });
+
+ await extension.startup();
+ assertPersistentListeners(extension, "theme", "onUpdated", {
+ primed: false,
+ });
+
+ await extension.terminateBackground();
+ assertPersistentListeners(extension, "theme", "onUpdated", {
+ primed: true,
+ });
+
+ info("Testing update event on static theme startup");
+ let updatedPromise = extension.awaitMessage("theme-updated");
+ await theme.startup();
+ const { theme: receivedTheme, windowId } = await updatedPromise;
+ Assert.ok(!windowId, "No window id in static theme update event");
+ Assert.ok(
+ receivedTheme.images.theme_frame.includes("image1.png"),
+ "Theme theme_frame image should be applied"
+ );
+ await theme.unload();
+ await extension.awaitMessage("theme-updated");
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js
new file mode 100644
index 0000000000..e4bd8cb99b
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js
@@ -0,0 +1,39 @@
+"use strict";
+
+// This test checks whether applied WebExtension themes that attempt to change
+// the color of the tab line are applied properly.
+
+add_task(async function test_support_tab_line() {
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+
+ const TAB_LINE_COLOR = "#ff0000";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: "#000",
+ tab_line: TAB_LINE_COLOR,
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ info("Checking selected tab line color");
+ let selectedTab = newWin.document.querySelector(".tabbrowser-tab[selected]");
+ let tab = selectedTab.querySelector(".tab-background");
+ let element = tab;
+ let property = "outline-color";
+ let computedValue = newWin.getComputedStyle(element)[property];
+ let expectedColor = `rgb(${hexToRGB(TAB_LINE_COLOR).join(", ")})`;
+
+ Assert.ok(
+ computedValue.includes(expectedColor),
+ `Tab line should be displayed in the box shadow of the tab: ${computedValue}`
+ );
+
+ await extension.unload();
+ await BrowserTestUtils.closeWindow(newWin);
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js
new file mode 100644
index 0000000000..1e402dbcc6
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js
@@ -0,0 +1,51 @@
+"use strict";
+
+add_task(async function test_support_tab_loading_filling() {
+ const TAB_LOADING_COLOR = "#FF0000";
+
+ // Make sure we use the animating loading icon
+ await SpecialPowers.pushPrefEnv({
+ set: [["ui.prefersReducedMotion", 0]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: "#000",
+ toolbar: "#124455",
+ tab_background_text: "#9400ff",
+ tab_loading: TAB_LOADING_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ info("Checking selected tab loading indicator colors");
+
+ let selectedTab = document.querySelector(
+ ".tabbrowser-tab[visuallyselected=true]"
+ );
+
+ selectedTab.setAttribute("busy", "true");
+ selectedTab.setAttribute("progress", "true");
+
+ let throbber = selectedTab.throbber;
+ Assert.equal(
+ window.getComputedStyle(throbber, "::before").fill,
+ `rgb(${hexToRGB(TAB_LOADING_COLOR).join(", ")})`,
+ "Throbber is filled with theme color"
+ );
+
+ selectedTab.removeAttribute("busy");
+ selectedTab.removeAttribute("progress");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js
new file mode 100644
index 0000000000..21f3c6d38b
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js
@@ -0,0 +1,54 @@
+"use strict";
+
+// This test checks whether applied WebExtension themes that attempt to change
+// the background color of selected tab are applied correctly.
+
+add_task(async function test_tab_background_color_property() {
+ const TAB_BACKGROUND_COLOR = "#9400ff";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ tab_selected: TAB_BACKGROUND_COLOR,
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ info("Checking selected tab color");
+
+ let openTab = document.querySelector(
+ ".tabbrowser-tab[visuallyselected=true]"
+ );
+ let openTabBackground = openTab.querySelector(".tab-background");
+
+ let selectedTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ let selectedTabBackground = selectedTab.querySelector(".tab-background");
+
+ let openTabGradient = window
+ .getComputedStyle(openTabBackground)
+ .getPropertyValue("background-image");
+ let selectedTabGradient = window
+ .getComputedStyle(selectedTabBackground)
+ .getPropertyValue("background-image");
+
+ let rgbRegex = /rgb\((\d{1,3}), (\d{1,3}), (\d{1,3})\)/g;
+ let selectedTabColors = selectedTabGradient.match(rgbRegex);
+
+ Assert.equal(
+ selectedTabColors[0],
+ "rgb(" + hexToRGB(TAB_BACKGROUND_COLOR).join(", ") + ")",
+ "Selected tab background color should be set."
+ );
+ Assert.equal(openTabGradient, "none");
+
+ gBrowser.removeTab(selectedTab);
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js
new file mode 100644
index 0000000000..d819f3a5f1
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js
@@ -0,0 +1,70 @@
+"use strict";
+
+// This test checks whether applied WebExtension themes that attempt to change
+// the text color of the selected tab are applied properly.
+
+add_task(async function test_support_tab_text_property_css_color() {
+ const TAB_TEXT_COLOR = "#9400ff";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ tab_text: TAB_TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ info("Checking selected tab colors");
+ let selectedTab = document.querySelector(".tabbrowser-tab[selected]");
+ Assert.equal(
+ window.getComputedStyle(selectedTab).color,
+ "rgb(" + hexToRGB(TAB_TEXT_COLOR).join(", ") + ")",
+ "Selected tab text color should be set."
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_support_tab_text_chrome_array() {
+ const TAB_TEXT_COLOR = [148, 0, 255];
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: FRAME_COLOR,
+ tab_background_text: TAB_BACKGROUND_TEXT_COLOR,
+ tab_text: TAB_TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ info("Checking selected tab colors");
+ let selectedTab = document.querySelector(".tabbrowser-tab[selected]");
+ Assert.equal(
+ window.getComputedStyle(selectedTab).color,
+ "rgb(" + TAB_TEXT_COLOR.join(", ") + ")",
+ "Selected tab text color should be set."
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js b/toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js
new file mode 100644
index 0000000000..39934200ac
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js
@@ -0,0 +1,48 @@
+"use strict";
+
+// This test checks whether the applied theme transition effects are applied
+// correctly.
+
+add_task(async function test_theme_transition_effects() {
+ const TOOLBAR = "#f27489";
+ const TEXT_COLOR = "#000000";
+ const TRANSITION_PROPERTY = "background-color";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ tab_background_text: TEXT_COLOR,
+ toolbar: TOOLBAR,
+ bookmark_text: TEXT_COLOR,
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ // check transition effect for toolbars
+ let navbar = document.querySelector("#nav-bar");
+ let navbarCS = window.getComputedStyle(navbar);
+
+ Assert.ok(
+ navbarCS
+ .getPropertyValue("transition-property")
+ .includes(TRANSITION_PROPERTY),
+ "Transition property set for #nav-bar"
+ );
+
+ let bookmarksBar = document.querySelector("#PersonalToolbar");
+ setToolbarVisibility(bookmarksBar, true, false, true);
+ let bookmarksBarCS = window.getComputedStyle(bookmarksBar);
+
+ Assert.ok(
+ bookmarksBarCS
+ .getPropertyValue("transition-property")
+ .includes(TRANSITION_PROPERTY),
+ "Transition property set for #PersonalToolbar"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js
new file mode 100644
index 0000000000..aa0446c453
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js
@@ -0,0 +1,183 @@
+"use strict";
+
+// This test checks whether applied WebExtension themes that attempt to change
+// the background color and the color of the navbar text fields are applied properly.
+
+const { CustomizableUITestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs"
+);
+let gCUITestUtils = new CustomizableUITestUtils(window);
+
+add_setup(async function () {
+ await gCUITestUtils.addSearchBar();
+ registerCleanupFunction(() => {
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+add_task(async function test_support_toolbar_field_properties() {
+ const TOOLBAR_FIELD_BACKGROUND = "#ff00ff";
+ const TOOLBAR_FIELD_COLOR = "#00ff00";
+ const TOOLBAR_FIELD_BORDER = "#aaaaff";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ toolbar_field: TOOLBAR_FIELD_BACKGROUND,
+ toolbar_field_text: TOOLBAR_FIELD_COLOR,
+ toolbar_field_border: TOOLBAR_FIELD_BORDER,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ let root = document.documentElement;
+ // Remove the `remotecontrol` attribute since it interferes with the urlbar styling.
+ root.removeAttribute("remotecontrol");
+ registerCleanupFunction(() => {
+ root.setAttribute("remotecontrol", "true");
+ });
+
+ let fields = [
+ document.querySelector("#urlbar-background"),
+ BrowserSearch.searchBar,
+ ].filter(field => {
+ let bounds = field.getBoundingClientRect();
+ return bounds.width > 0 && bounds.height > 0;
+ });
+
+ Assert.equal(fields.length, 2, "Should be testing two elements");
+
+ info(
+ `Checking toolbar background colors and colors for ${fields.length} toolbar fields.`
+ );
+ for (let field of fields) {
+ info(`Testing ${field.id || field.className}`);
+ Assert.equal(
+ window.getComputedStyle(field).backgroundColor,
+ hexToCSS(TOOLBAR_FIELD_BACKGROUND),
+ "Field background should be set."
+ );
+ Assert.equal(
+ window.getComputedStyle(field).color,
+ hexToCSS(TOOLBAR_FIELD_COLOR),
+ "Field color should be set."
+ );
+ testBorderColor(field, TOOLBAR_FIELD_BORDER);
+ }
+
+ await extension.unload();
+});
+
+add_task(async function test_support_toolbar_field_brighttext() {
+ let root = document.documentElement;
+ // Remove the `remotecontrol` attribute since it interferes with the urlbar styling.
+ root.removeAttribute("remotecontrol");
+ registerCleanupFunction(() => {
+ root.setAttribute("remotecontrol", "true");
+ });
+ let urlbar = gURLBar.textbox;
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ toolbar_field: "#fff",
+ toolbar_field_text: "#000",
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ Assert.equal(
+ window.getComputedStyle(urlbar).color,
+ hexToCSS("#000000"),
+ "Color has been set"
+ );
+ Assert.ok(
+ !root.hasAttribute("lwt-toolbar-field-brighttext"),
+ "Brighttext attribute should not be set"
+ );
+
+ await extension.unload();
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ toolbar_field: "#000",
+ toolbar_field_text: "#fff",
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ Assert.equal(
+ window.getComputedStyle(urlbar).color,
+ hexToCSS("#ffffff"),
+ "Color has been set"
+ );
+ Assert.ok(
+ root.hasAttribute("lwt-toolbar-field-brighttext"),
+ "Brighttext attribute should be set"
+ );
+
+ await extension.unload();
+});
+
+// Verifies that we apply the lwt-toolbar-field-brighttext attribute when
+// toolbar fields are dark text on a dark background.
+add_task(async function test_support_toolbar_field_brighttext_dark_on_dark() {
+ let root = document.documentElement;
+ // Remove the `remotecontrol` attribute since it interferes with the urlbar styling.
+ root.removeAttribute("remotecontrol");
+ registerCleanupFunction(() => {
+ root.setAttribute("remotecontrol", "true");
+ });
+ let urlbar = gURLBar.textbox;
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ toolbar_field: "#000",
+ toolbar_field_text: "#111111",
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ Assert.equal(
+ window.getComputedStyle(urlbar).color,
+ hexToCSS("#111111"),
+ "Color has been set"
+ );
+ Assert.ok(
+ root.hasAttribute("lwt-toolbar-field-brighttext"),
+ "Brighttext attribute should be set"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js
new file mode 100644
index 0000000000..ff6af3ade7
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js
@@ -0,0 +1,107 @@
+"use strict";
+
+add_setup(async function () {
+ // Remove the `remotecontrol` attribute since it interferes with the urlbar styling.
+ document.documentElement.removeAttribute("remotecontrol");
+ registerCleanupFunction(() => {
+ document.documentElement.setAttribute("remotecontrol", "true");
+ });
+});
+
+add_task(async function test_toolbar_field_focus() {
+ const TOOLBAR_FIELD_BACKGROUND = "#FF00FF";
+ const TOOLBAR_FIELD_COLOR = "#00FF00";
+ const TOOLBAR_FOCUS_BACKGROUND = "#FF0000";
+ const TOOLBAR_FOCUS_TEXT = "#9400FF";
+ const TOOLBAR_FOCUS_BORDER = "#FFFFFF";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: "#FF0000",
+ tab_background_color: "#ffffff",
+ toolbar_field: TOOLBAR_FIELD_BACKGROUND,
+ toolbar_field_text: TOOLBAR_FIELD_COLOR,
+ toolbar_field_focus: TOOLBAR_FOCUS_BACKGROUND,
+ toolbar_field_text_focus: TOOLBAR_FOCUS_TEXT,
+ toolbar_field_border_focus: TOOLBAR_FOCUS_BORDER,
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ info("Checking toolbar field's focus color");
+
+ let urlBar = document.querySelector("#urlbar-background");
+ gURLBar.textbox.setAttribute("focused", "true");
+ let style = window.getComputedStyle(urlBar);
+
+ Assert.equal(
+ style.backgroundColor,
+ `rgb(${hexToRGB(TOOLBAR_FOCUS_BACKGROUND).join(", ")})`,
+ "Background Color is changed"
+ );
+ Assert.equal(
+ style.color,
+ `rgb(${hexToRGB(TOOLBAR_FOCUS_TEXT).join(", ")})`,
+ "Text Color is changed"
+ );
+ Assert.equal(
+ style.outlineColor,
+ `rgb(${hexToRGB(TOOLBAR_FOCUS_BORDER).join(", ")})`,
+ "Focus ring color"
+ );
+
+ gURLBar.textbox.removeAttribute("focused");
+
+ Assert.equal(
+ style.backgroundColor,
+ `rgb(${hexToRGB(TOOLBAR_FIELD_BACKGROUND).join(", ")})`,
+ "Background Color is set back to initial"
+ );
+ Assert.equal(
+ style.color,
+ `rgb(${hexToRGB(TOOLBAR_FIELD_COLOR).join(", ")})`,
+ "Text Color is set back to initial"
+ );
+ await extension.unload();
+});
+
+add_task(async function test_toolbar_field_focus_low_alpha() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: "#FF0000",
+ tab_background_color: "#ffffff",
+ toolbar_field: "#FF00FF",
+ toolbar_field_text: "#00FF00",
+ toolbar_field_focus: "rgba(0, 0, 255, 0.4)",
+ toolbar_field_text_focus: "red",
+ toolbar_field_border_focus: "#FFFFFF",
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+ gURLBar.textbox.setAttribute("focused", "true");
+
+ let urlBar = document.querySelector("#urlbar-background");
+ Assert.equal(
+ window.getComputedStyle(urlBar).backgroundColor,
+ `rgba(0, 0, 255, 0.9)`,
+ "Background color has minimum opacity enforced"
+ );
+ Assert.equal(
+ window.getComputedStyle(urlBar).color,
+ `rgb(255, 255, 255)`,
+ "Text color has been overridden to match background"
+ );
+
+ gURLBar.textbox.removeAttribute("focused");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js
new file mode 100644
index 0000000000..37c082b36f
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js
@@ -0,0 +1,63 @@
+"use strict";
+
+/* globals InspectorUtils */
+
+// This test checks whether applied WebExtension themes that attempt to change
+// the button background color properties are applied correctly.
+
+add_task(async function setup_home_button() {
+ CustomizableUI.addWidgetToArea("home-button", "nav-bar");
+ registerCleanupFunction(() =>
+ CustomizableUI.removeWidgetFromArea("home-button")
+ );
+});
+
+add_task(async function test_button_background_properties() {
+ const BUTTON_BACKGROUND_ACTIVE = "#FFFFFF";
+ const BUTTON_BACKGROUND_HOVER = "#59CBE8";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ button_background_active: BUTTON_BACKGROUND_ACTIVE,
+ button_background_hover: BUTTON_BACKGROUND_HOVER,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ let toolbarButton = document.querySelector("#home-button");
+ let toolbarButtonIcon = toolbarButton.icon;
+ let toolbarButtonIconCS = window.getComputedStyle(toolbarButtonIcon);
+
+ InspectorUtils.addPseudoClassLock(toolbarButton, ":hover");
+
+ Assert.equal(
+ toolbarButtonIconCS.getPropertyValue("background-color"),
+ `rgb(${hexToRGB(BUTTON_BACKGROUND_HOVER).join(", ")})`,
+ "Toolbar button hover background is set."
+ );
+
+ InspectorUtils.addPseudoClassLock(toolbarButton, ":active");
+
+ Assert.equal(
+ toolbarButtonIconCS.getPropertyValue("background-color"),
+ `rgb(${hexToRGB(BUTTON_BACKGROUND_ACTIVE).join(", ")})`,
+ "Toolbar button active background is set!"
+ );
+
+ InspectorUtils.clearPseudoClassLocks(toolbarButton);
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js
new file mode 100644
index 0000000000..2802c6ac33
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js
@@ -0,0 +1,109 @@
+"use strict";
+
+// This test checks applied WebExtension themes that attempt to change
+// icon color properties
+
+add_task(async function setup_home_button() {
+ CustomizableUI.addWidgetToArea("home-button", "nav-bar");
+ registerCleanupFunction(() =>
+ CustomizableUI.removeWidgetFromArea("home-button")
+ );
+});
+
+add_task(async function test_icons_properties() {
+ const ICONS_COLOR = "#001b47";
+ const ICONS_ATTENTION_COLOR = "#44ba77";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ icons: ICONS_COLOR,
+ icons_attention: ICONS_ATTENTION_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ let toolbarbutton = document.querySelector("#home-button");
+ Assert.equal(
+ window.getComputedStyle(toolbarbutton).getPropertyValue("fill"),
+ `rgb(${hexToRGB(ICONS_COLOR).join(", ")})`,
+ "Buttons fill color set!"
+ );
+
+ let starButton = document.querySelector("#star-button");
+ starButton.setAttribute("starred", "true");
+
+ let starComputedStyle = window.getComputedStyle(starButton);
+ Assert.equal(
+ starComputedStyle.getPropertyValue(
+ "--lwt-toolbarbutton-icon-fill-attention"
+ ),
+ `rgb(${hexToRGB(ICONS_ATTENTION_COLOR).join(", ")})`,
+ "Variable is properly set"
+ );
+ Assert.equal(
+ starComputedStyle.getPropertyValue("fill"),
+ `rgb(${hexToRGB(ICONS_ATTENTION_COLOR).join(", ")})`,
+ "Starred icon fill is properly set"
+ );
+
+ starButton.removeAttribute("starred");
+
+ await extension.unload();
+});
+
+add_task(async function test_no_icons_properties() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ let toolbarbutton = document.querySelector("#home-button");
+ let toolbarbuttonCS = window.getComputedStyle(toolbarbutton);
+ let currentColor = toolbarbuttonCS.getPropertyValue("color");
+ Assert.equal(
+ window.getComputedStyle(toolbarbutton).getPropertyValue("fill"),
+ currentColor,
+ "Button fill color should be currentColor when no icon color specified."
+ );
+
+ let starButton = document.querySelector("#star-button");
+ starButton.setAttribute("starred", "true");
+ let starComputedStyle = window.getComputedStyle(starButton);
+ Assert.equal(
+ starComputedStyle.getPropertyValue(
+ "--lwt-toolbarbutton-icon-fill-attention"
+ ),
+ "",
+ "Icon attention fill should not be set when the value is not specified in the manifest."
+ );
+ starButton.removeAttribute("starred");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js
new file mode 100644
index 0000000000..ee31d80888
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js
@@ -0,0 +1,105 @@
+"use strict";
+
+// This test checks whether applied WebExtension themes that attempt to change
+// the background color of toolbars are applied properly.
+
+add_task(async function test_support_toolbar_property() {
+ const TOOLBAR_COLOR = "#ff00ff";
+ const TOOLBAR_TEXT_COLOR = "#9400ff";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ toolbar: TOOLBAR_COLOR,
+ toolbar_text: TOOLBAR_TEXT_COLOR,
+ },
+ },
+ },
+ });
+
+ let toolbox = document.querySelector("#navigator-toolbox");
+ let toolbars = [
+ ...toolbox.querySelectorAll("toolbar:not(#TabsToolbar)"),
+ ].filter(toolbar => {
+ let bounds = toolbar.getBoundingClientRect();
+ return bounds.width > 0 && bounds.height > 0;
+ });
+
+ let transitionPromise = waitForTransition(toolbars[0], "background-color");
+ await extension.startup();
+ await transitionPromise;
+
+ info(`Checking toolbar colors for ${toolbars.length} toolbars.`);
+ for (let toolbar of toolbars) {
+ info(`Testing ${toolbar.id}`);
+ Assert.equal(
+ window.getComputedStyle(toolbar).backgroundColor,
+ hexToCSS(TOOLBAR_COLOR),
+ "Toolbar background color should be set."
+ );
+ Assert.equal(
+ window.getComputedStyle(toolbar).color,
+ hexToCSS(TOOLBAR_TEXT_COLOR),
+ "Toolbar text color should be set."
+ );
+ }
+
+ info("Checking selected tab colors");
+ let selectedTab = document.querySelector(".tabbrowser-tab[selected]");
+ Assert.equal(
+ window.getComputedStyle(selectedTab).color,
+ hexToCSS(TOOLBAR_TEXT_COLOR),
+ "Selected tab text color should be set."
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_bookmark_text_property() {
+ const TOOLBAR_COLOR = [255, 0, 255];
+ const TOOLBAR_TEXT_COLOR = [48, 0, 255];
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ toolbar: TOOLBAR_COLOR,
+ bookmark_text: TOOLBAR_TEXT_COLOR,
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let toolbox = document.querySelector("#navigator-toolbox");
+ let toolbars = [
+ ...toolbox.querySelectorAll("toolbar:not(#TabsToolbar)"),
+ ].filter(toolbar => {
+ let bounds = toolbar.getBoundingClientRect();
+ return bounds.width > 0 && bounds.height > 0;
+ });
+
+ info(`Checking toolbar colors for ${toolbars.length} toolbars.`);
+ for (let toolbar of toolbars) {
+ info(`Testing ${toolbar.id}`);
+ Assert.equal(
+ window.getComputedStyle(toolbar).color,
+ rgbToCSS(TOOLBAR_TEXT_COLOR),
+ "bookmark_text should be an alias for toolbar_text"
+ );
+ }
+
+ info("Checking selected tab colors");
+ let selectedTab = document.querySelector(".tabbrowser-tab[selected]");
+ Assert.equal(
+ window.getComputedStyle(selectedTab).color,
+ rgbToCSS(TOOLBAR_TEXT_COLOR),
+ "Selected tab text color should be set."
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js b/toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js
new file mode 100644
index 0000000000..025a4073dd
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js
@@ -0,0 +1,144 @@
+"use strict";
+
+const { AddonSettings } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonSettings.sys.mjs"
+);
+
+// This test checks that theme warnings are properly emitted.
+
+function waitForConsole(task, message) {
+ // eslint-disable-next-line no-async-promise-executor
+ return new Promise(async resolve => {
+ SimpleTest.monitorConsole(resolve, [
+ {
+ message: new RegExp(message),
+ },
+ ]);
+ await task();
+ SimpleTest.endMonitorConsole();
+ });
+}
+
+add_setup(async function () {
+ SimpleTest.waitForExplicitFinish();
+});
+
+add_task(async function test_static_theme() {
+ for (const property of ["colors", "images", "properties"]) {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ [property]: {
+ such_property: "much_wow",
+ },
+ },
+ },
+ });
+ await waitForConsole(
+ extension.startup,
+ `Unrecognized theme property found: ${property}.such_property`
+ );
+ await extension.unload();
+ }
+});
+
+add_task(async function test_dynamic_theme() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["theme"],
+ },
+ background() {
+ browser.test.onMessage.addListener((msg, details) => {
+ if (msg === "update-theme") {
+ browser.theme.update(details).then(() => {
+ browser.test.sendMessage("theme-updated");
+ });
+ } else {
+ browser.theme.reset().then(() => {
+ browser.test.sendMessage("theme-reset");
+ });
+ }
+ });
+ },
+ });
+
+ await extension.startup();
+
+ for (const property of ["colors", "images", "properties"]) {
+ extension.sendMessage("update-theme", {
+ [property]: {
+ such_property: "much_wow",
+ },
+ });
+ await waitForConsole(
+ () => extension.awaitMessage("theme-updated"),
+ `Unrecognized theme property found: ${property}.such_property`
+ );
+ }
+
+ await extension.unload();
+});
+
+add_task(async function test_experiments_enabled() {
+ info("Testing that experiments are handled correctly on nightly and deved");
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ theme: {
+ properties: {
+ such_property: "much_wow",
+ unknown_property: "very_unknown",
+ },
+ },
+ theme_experiment: {
+ properties: {
+ such_property: "--such-property",
+ },
+ },
+ },
+ });
+ if (!AddonSettings.EXPERIMENTS_ENABLED) {
+ await waitForConsole(
+ extension.startup,
+ "This extension is not allowed to run theme experiments"
+ );
+ } else {
+ await waitForConsole(
+ extension.startup,
+ "Unrecognized theme property found: properties.unknown_property"
+ );
+ }
+ await extension.unload();
+});
+
+add_task(async function test_experiments_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.experiments.enabled", false]],
+ });
+
+ info(
+ "Testing that experiments are handled correctly when experiements pref is disabled"
+ );
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ properties: {
+ such_property: "much_wow",
+ },
+ },
+ theme_experiment: {
+ properties: {
+ such_property: "--such-property",
+ },
+ },
+ },
+ });
+ await waitForConsole(
+ extension.startup,
+ "This extension is not allowed to run theme experiments"
+ );
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js b/toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js
new file mode 100644
index 0000000000..96a2216067
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js
@@ -0,0 +1,94 @@
+"use strict";
+
+/* import-globals-from ../../../thumbnails/test/head.js */
+loadTestSubscript("../../../thumbnails/test/head.js");
+
+// The service that creates thumbnails of webpages in the background loads a
+// web page in the background (with several features disabled). Extensions
+// should be able to observe requests, but not run content scripts.
+add_task(async function test_thumbnails_background_visibility_to_extensions() {
+ const iframeUrl = "http://example.com/?iframe";
+ const testPageUrl = bgTestPageURL({ iframe: iframeUrl });
+ // ^ testPageUrl is http://mochi.test:8888/.../thumbnails_background.sjs?...
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ // ":8888" omitted due to bug 1362809.
+ matches: [
+ "http://mochi.test/*/thumbnails_background.sjs*",
+ "http://example.com/?iframe*",
+ ],
+ js: ["contentscript.js"],
+ run_at: "document_start",
+ all_frames: true,
+ },
+ ],
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "http://example.com/*",
+ "http://mochi.test/*",
+ ],
+ },
+ files: {
+ "contentscript.js": () => {
+ // Content scripts are not expected to be run in the page of the
+ // thumbnail service, so this should never execute.
+ new Image().src = "http://example.com/?unexpected-content-script";
+ browser.test.fail("Content script ran in thumbs, unexpectedly.");
+ },
+ },
+ background() {
+ let requests = [];
+ browser.webRequest.onBeforeRequest.addListener(
+ ({ url, tabId, frameId, type }) => {
+ browser.test.assertEq(-1, tabId, "Thumb page is not a tab");
+ // We want to know if frameId is 0 or non-negative (or possibly -1).
+ if (type === "sub_frame") {
+ browser.test.assertTrue(frameId > 0, `frame ${frameId} for ${url}`);
+ } else {
+ browser.test.assertEq(0, frameId, `frameId for ${type} ${url}`);
+ }
+ requests.push({ type, url });
+ },
+ {
+ types: ["main_frame", "sub_frame", "image"],
+ urls: ["*://*/*"],
+ },
+ ["blocking"]
+ );
+ browser.test.onMessage.addListener(msg => {
+ browser.test.assertEq("get-results", msg, "expected message");
+ browser.test.sendMessage("webRequest-results", requests);
+ });
+ },
+ });
+
+ await extension.startup();
+
+ ok(!thumbnailExists(testPageUrl), "Thumbnail should not be cached yet.");
+
+ await bgCapture(testPageUrl);
+ ok(thumbnailExists(testPageUrl), "Thumbnail should be cached after capture");
+ removeThumbnail(testPageUrl);
+
+ extension.sendMessage("get-results");
+ Assert.deepEqual(
+ await extension.awaitMessage("webRequest-results"),
+ [
+ {
+ type: "main_frame",
+ url: testPageUrl,
+ },
+ {
+ type: "sub_frame",
+ url: iframeUrl,
+ },
+ ],
+ "Expected requests via webRequest"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_webNavigation_eventpage.js b/toolkit/components/extensions/test/browser/browser_ext_webNavigation_eventpage.js
new file mode 100644
index 0000000000..d898cb96a4
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_webNavigation_eventpage.js
@@ -0,0 +1,72 @@
+"use strict";
+
+add_task(async function webnav_test_eventpage() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.eventPages.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webNavigation", "*://mochi.test/*"],
+ background: { persistent: false },
+ },
+ background() {
+ const EVENTS = [
+ "onTabReplaced",
+ "onBeforeNavigate",
+ "onCommitted",
+ "onDOMContentLoaded",
+ "onCompleted",
+ "onErrorOccurred",
+ "onReferenceFragmentUpdated",
+ "onHistoryStateUpdated",
+ ];
+
+ for (let event of EVENTS) {
+ browser.webNavigation[event].addListener(() => {});
+ }
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ // onTabReplaced is never persisted, it is an empty event handler.
+ const EVENTS = [
+ "onBeforeNavigate",
+ "onCommitted",
+ "onDOMContentLoaded",
+ "onCompleted",
+ "onErrorOccurred",
+ "onReferenceFragmentUpdated",
+ "onHistoryStateUpdated",
+ ];
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ for (let event of EVENTS) {
+ assertPersistentListeners(extension, "webNavigation", event, {
+ primed: false,
+ });
+ }
+
+ await extension.terminateBackground();
+ for (let event of EVENTS) {
+ assertPersistentListeners(extension, "webNavigation", event, {
+ primed: true,
+ });
+ }
+
+ // wake up the background, we don't really care which event does it,
+ // we're just verifying the state after.
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ await extension.awaitMessage("ready");
+ for (let event of EVENTS) {
+ assertPersistentListeners(extension, "webNavigation", event, {
+ primed: false,
+ });
+ }
+
+ await BrowserTestUtils.closeWindow(newWin);
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js b/toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js
new file mode 100644
index 0000000000..674a10a5ef
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js
@@ -0,0 +1,48 @@
+"use strict";
+
+// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1573456
+add_task(async function test_mozextension_page_loaded_in_extension_process() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "https://example.com/*",
+ ],
+ web_accessible_resources: ["test.html"],
+ },
+ files: {
+ "test.html": '<!DOCTYPE html><script src="test.js"></script>',
+ "test.js": () => {
+ browser.test.assertTrue(
+ browser.webRequest,
+ "webRequest API should be available"
+ );
+
+ browser.test.sendMessage("test_done");
+ },
+ },
+ background: () => {
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ return {
+ redirectUrl: browser.runtime.getURL("test.html"),
+ };
+ },
+ { urls: ["*://*/redir"] },
+ ["blocking"]
+ );
+ },
+ });
+ await extension.startup();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com/redir"
+ );
+
+ await extension.awaitMessage("test_done");
+
+ await extension.unload();
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js b/toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js
new file mode 100644
index 0000000000..666d4f324f
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js
@@ -0,0 +1,133 @@
+"use strict";
+
+// Check that extension popup windows contain the name of the extension
+// as well as the title of the loaded document, but not the URL.
+add_task(async function test_popup_title() {
+ const name = "custom_title_number_9_please";
+ const docTitle = "popup-test-title";
+
+ const extensionWithImplicitHostPermission = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name,
+ },
+ async background() {
+ let popup;
+
+ // Called after the popup loads
+ browser.runtime.onMessage.addListener(async ({ docTitle }) => {
+ const name = browser.runtime.getManifest().name;
+ const { id } = await popup;
+ const { title } = await browser.windows.get(id);
+
+ browser.test.assertTrue(
+ title.includes(name),
+ "popup title must include extension name"
+ );
+ browser.test.assertTrue(
+ title.includes(docTitle),
+ "popup title must include extension document title"
+ );
+ browser.test.assertFalse(
+ title.includes("moz-extension:"),
+ "popup title must not include extension URL"
+ );
+
+ // share window data with other extensions
+ browser.test.sendMessage("windowData", {
+ id: id,
+ fullTitle: title,
+ });
+
+ browser.test.onMessage.addListener(async message => {
+ if (message === "cleanup") {
+ await browser.windows.remove(id);
+ browser.test.sendMessage("finishedCleanup");
+ }
+ });
+
+ browser.test.sendMessage("done");
+ });
+
+ popup = browser.windows.create({
+ url: "/index.html",
+ type: "popup",
+ });
+ },
+ files: {
+ "index.html": `<!doctype html>
+ <meta charset="utf-8">
+ <title>${docTitle}</title>,
+ <script src="index.js"></script>
+ `,
+ "index.js": `addEventListener(
+ "load",
+ () => browser.runtime.sendMessage({docTitle: document.title})
+ );`,
+ },
+ });
+
+ const extensionWithoutPermissions = ExtensionTestUtils.loadExtension({
+ async background() {
+ const { id } = await new Promise(resolve => {
+ browser.test.onMessage.addListener(message => {
+ resolve(message);
+ });
+ });
+
+ const { title } = await browser.windows.get(id);
+
+ browser.test.assertEq(
+ title,
+ undefined,
+ "popup window must not include title"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ const extensionWithTabsPermission = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs"],
+ },
+ async background() {
+ const { id, fullTitle } = await new Promise(resolve => {
+ browser.test.onMessage.addListener(message => {
+ resolve(message);
+ });
+ });
+
+ const { title } = await browser.windows.get(id);
+
+ browser.test.assertEq(
+ title,
+ fullTitle,
+ "popup title equals expected title"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extensionWithoutPermissions.startup();
+ await extensionWithTabsPermission.startup();
+ await extensionWithImplicitHostPermission.startup();
+
+ const windowData = await extensionWithImplicitHostPermission.awaitMessage(
+ "windowData"
+ );
+
+ extensionWithoutPermissions.sendMessage(windowData);
+ extensionWithTabsPermission.sendMessage(windowData);
+
+ await extensionWithoutPermissions.awaitMessage("done");
+ await extensionWithTabsPermission.awaitMessage("done");
+ await extensionWithImplicitHostPermission.awaitMessage("done");
+
+ extensionWithImplicitHostPermission.sendMessage("cleanup");
+ await extensionWithImplicitHostPermission.awaitMessage("finishedCleanup");
+
+ await extensionWithoutPermissions.unload();
+ await extensionWithTabsPermission.unload();
+ await extensionWithImplicitHostPermission.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/data/test-download.txt b/toolkit/components/extensions/test/browser/data/test-download.txt
new file mode 100644
index 0000000000..f416e0e291
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/data/test-download.txt
@@ -0,0 +1 @@
+test download content
diff --git a/toolkit/components/extensions/test/browser/data/test_downloads_referrer.html b/toolkit/components/extensions/test/browser/data/test_downloads_referrer.html
new file mode 100644
index 0000000000..85410abfcd
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/data/test_downloads_referrer.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Test downloads referrer</title>
+ </head>
+ <body>
+ <a href="test-download.txt" class="test-link">test link</a>
+ </body>
+</html>
diff --git a/toolkit/components/extensions/test/browser/head.js b/toolkit/components/extensions/test/browser/head.js
new file mode 100644
index 0000000000..e25d4cb594
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/head.js
@@ -0,0 +1,126 @@
+/* exported ACCENT_COLOR, BACKGROUND, ENCODED_IMAGE_DATA, FRAME_COLOR, TAB_TEXT_COLOR,
+ TEXT_COLOR, TAB_BACKGROUND_TEXT_COLOR, imageBufferFromDataURI, hexToCSS, hexToRGB, testBorderColor,
+ waitForTransition, loadTestSubscript, backgroundColorSetOnRoot, assertPersistentListeners */
+
+"use strict";
+
+const { ClientEnvironmentBase } = ChromeUtils.importESModule(
+ "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs"
+);
+
+const BACKGROUND =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0" +
+ "DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
+const ENCODED_IMAGE_DATA =
+ "iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0h" +
+ "STQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAAdhwAAHYcBj+XxZQAAB5dJREFUSMd" +
+ "91vmTlEcZB/Bvd7/vO+/ce83O3gfLDUsC4VgIghBUEo2GM9GCFTaQBEISA1qIEVNQ4aggJDGIgAGTlFUKKcqKQpVHaQyny7FrCMiywp4ze+/Mzs67M/P" +
+ "O+3a3v5jdWo32H/B86vv0U083weecV3+0C8lkEh6PhzS3tuLkieMSAKo3fW9Mb1eoUtM0jemerukLllzrbGlKheovUpeqkmt113hPfx/27tyFF7+/bbg" +
+ "e+U9g20s7kEwmMXXGNLrp2fWi4V5z/tFjJ3fWX726INbfU2xx0yelkJAKdJf3Xl5+2QcPTpv2U0JZR+u92+xvly5ygKDm20/hlX17/jvB6VNnIKXEOyd" +
+ "O0iFh4PLVy0XV1U83Vk54QI7JK+bl+UE5vjRfTCzJ5eWBTFEayBLjisvljKmzwmtWrVkEAPNmVrEZkyfh+fU1n59k//7X4Fbz8MK2DRSAWLNq/Yc36y9" +
+ "+3UVMsyAYVPMy/MTvdBKvriJhphDq6xa9vf0i1GMwPVhM5s9bsLw/EvtN2kywwnw/nzBuLDZs2z4auXGjHuvWbmBQdT5v7qytn165fLCyyGtXTR6j5GV" +
+ "kIsvlBCwTVNgQhMKCRDQ2iIbmJv7BpU+Ykl02UFOzdt6gkbzTEQ5Rl2KL3W8eGUE+/ssFXK+rJQ8vWigLgjk5z9ZsvpOniJzVi+ZKTUhCuATTKCjhoLA" +
+ "hhQAsjrSZBJcm7rZ22O+ev6mMmTLj55eu1T+jU8GOH/kJf2TZCiifIQsXfwEbN2yktxoaeYbf93DKSORMnTOZE0aZaVlQGYVKJCgjEJSCcgLB0xDERjI" +
+ "NFBUEaXmuB20t95eEutr0xrufpo4eepMAkMPIxx+dx9at25EWQNXsh77q0Bzwen0ShEF32HCrCpjksAWHFAKqokFhgEJt2DKJeFoQv8eDuz3duaseXZY" +
+ "dixthaQ+NRlRCcKO+FgCweP68wswMF/yZWcTkNpLJFAZEGi6XC07NCUIIoqaNSLQfFALCEpCSEL/bK/wuw+12sKlDQzKs6k5yZt+rI+2aNKUSNdUbSSQ" +
+ "Wh2mJP46rGPeYrjtkY0M7jFgciUQCiqqgrCAfBTle3G9rR1NHN3SnDq9Lg49QlBQEcbfbQCKZlhQEDkXBih27RpDOrmacfP8YB4CfHT7uNXrCMFM2FdD" +
+ "BVQ5TE/A5HbDSJoSpQXAbXm8A4b5+gKrwulU4KKEBnwuzHpiQu+n1jQoQsM+9cYQMT9fvf/FLBYTaDqdzbfgft95PKzbPyQqwnlAXGkJtGIgNYnJpMfw" +
+ "OghLG0GJE0ZdiaOnsQ16OD6XZLkiRROdAgud5sxk8ridsy/pQU1VlOIkZN6QtAGnx0FA0AtXvIA4C5OX4kOWbiLRhQBDApTmgJuLwEonMgBvjgpmgjIE" +
+ "hhX7DAIVKNeqE05/dJbgEgRy5eOJ1ieXr1gJA7ZNLTrVVlAZLyopLJAUlHsrAMrwwrRQ4t6E5VHgSBExjcGpO0JQNizCE05a41dhOi+cXXVm144e1AHD" +
+ "1vXfFMOLy+KSHEDoEJLZ8s+ZWKpUusWwpFKiMUQ4jbiAaj8Hp9oExBsMCUpEIfD6JLKZjKJVGV3RIZGdm0qxA5qmz+/cgMhBVuuMRewRRGF7fe4BYHMg" +
+ "N5LxdV3vhy1EjrrjA5GAyTuKpFHricfS0dSDNCQRPoSyQgSSPI+UBEtwShiWUQEHw5mMvbz4JRcXvDr3B3dBG1sq5X53GlMcX4JWVTyvRQcOumDD2vfK" +
+ "cjOqiQDZPGBF2ryUEnjRhJlP4d6/BiQ1TABPKiyQhgtzvjPCJlQ/OGRwauqESSUPX68U3Vi4fGeH83Hwc3bYHBWUV0m0k4HB6z7aGu6sznDos00R3exg" +
+ "l5ZMwc+FMaJoKKxHFnbo6DMYiELBlqLOXDBq8dsvuPTfKALpwdbX42iMLsHjLd0Zv4RNvvY1wZxdZunyVDGZm6D/47sv12RqbmOPVhG5LGnAH4S8sgu7" +
+ "1oK/pn2BWAoYw0dDbaTd19iqlZROejwzEjqgMSuXUifak8jF49JnNI0kAoGrBfET7+uXOrS+y5ta21JzZsw7faW45XJaXxSvyAtTpkOi483fwtAWP1wt" +
+ "vrhvd/VFx+26zojr9Les2PnfaTNu4cuGvvKe9BVv3/RgARiNTpk/Hod17MWikxcqzzfhK/+1jL2xc+YQAX1ISDHLV7WTpQQaLcASzPEiB41ZrmEeHkrT" +
+ "Q49uz/aXn+iilLKXq/MmlS0e/jFcuX4SmaQAAKSXlnIvVy1aQ6EBMFgRyCznDpfGFwdKqirF2tu5SdIeGrkiP+KS5yb7dHtIKsnI++kP9rS8RQvjmxxe" +
+ "jePxD2HHwwP9FdCllurGhUbx14CAbiMc4Y2qVJqwLbo0qfpdLSilILB4Xg0mT6h7vnSWzZn9RoaynobWF3K6rk1NmzMWZ83/+37+V4a1cVg5JACYF45b" +
+ "FGVVWOFS2V1HUCjOdBqW0Q9fYb7N9/tcSptnldjpott8rFEXBO+f+NKrWMHL9Wu1nSUAIAaUUa59aAyE43E4X3bD8W6K5K6x1h1snRaMDJDuQf7+vrzf" +
+ "eG+mgfrcLHh3C79bx6wttGEqERiH/AjPohWMouv2ZAAAAAElFTkSuQmCC";
+const ACCENT_COLOR = "#a14040";
+const TEXT_COLOR = "#fac96e";
+// For testing aliases of the colors above:
+const FRAME_COLOR = [71, 105, 91];
+const TAB_BACKGROUND_TEXT_COLOR = [207, 221, 192, 0.9];
+
+function hexToRGB(hex) {
+ if (!hex) {
+ return null;
+ }
+ hex = parseInt(hex.indexOf("#") > -1 ? hex.substring(1) : hex, 16);
+ return [hex >> 16, (hex & 0x00ff00) >> 8, hex & 0x0000ff];
+}
+
+function rgbToCSS(rgb) {
+ return `rgb(${rgb.join(", ")})`;
+}
+
+function hexToCSS(hex) {
+ if (!hex) {
+ return null;
+ }
+ return rgbToCSS(hexToRGB(hex));
+}
+
+function imageBufferFromDataURI(encodedImageData) {
+ let decodedImageData = atob(encodedImageData);
+ return Uint8Array.from(decodedImageData, byte => byte.charCodeAt(0)).buffer;
+}
+
+function waitForTransition(element, propertyName) {
+ return BrowserTestUtils.waitForEvent(
+ element,
+ "transitionend",
+ false,
+ event => {
+ return event.target == element && event.propertyName == propertyName;
+ }
+ );
+}
+
+function testBorderColor(element, expected) {
+ let computedStyle = window.getComputedStyle(element);
+ Assert.equal(
+ computedStyle.borderLeftColor,
+ hexToCSS(expected),
+ "Element left border color should be set."
+ );
+ Assert.equal(
+ computedStyle.borderRightColor,
+ hexToCSS(expected),
+ "Element right border color should be set."
+ );
+ Assert.equal(
+ computedStyle.borderTopColor,
+ hexToCSS(expected),
+ "Element top border color should be set."
+ );
+ Assert.equal(
+ computedStyle.borderBottomColor,
+ hexToCSS(expected),
+ "Element bottom border color should be set."
+ );
+}
+
+function loadTestSubscript(filePath) {
+ Services.scriptloader.loadSubScript(new URL(filePath, gTestPath).href, this);
+}
+
+/**
+ * Windows 7 and 8 set the window's background-color on :root instead of
+ * #navigator-toolbox to avoid bug 1695280. When that bug is fixed, this
+ * function and the assertions it gates can be removed.
+ *
+ * @returns {boolean} True if the window's background-color is set on :root
+ * rather than #navigator-toolbox.
+ */
+function backgroundColorSetOnRoot() {
+ const os = ClientEnvironmentBase.os;
+ if (!os.isWindows) {
+ return false;
+ }
+ return os.windowsVersion < 10;
+}
+
+// Persistent Listener test functionality
+const { assertPersistentListeners } = ExtensionTestUtils.testAssertions;
diff --git a/toolkit/components/extensions/test/browser/head_serviceworker.js b/toolkit/components/extensions/test/browser/head_serviceworker.js
new file mode 100644
index 0000000000..b2f9512e5a
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/head_serviceworker.js
@@ -0,0 +1,119 @@
+"use strict";
+
+/* exported assert_background_serviceworker_pref_enabled,
+ * getBackgroundServiceWorkerRegistration,
+ * getServiceWorkerInfo, getServiceWorkerState,
+ * waitForServiceWorkerRegistrationsRemoved, waitForServiceWorkerTerminated
+ */
+
+async function assert_background_serviceworker_pref_enabled() {
+ is(
+ WebExtensionPolicy.backgroundServiceWorkerEnabled,
+ true,
+ "Expect extensions.backgroundServiceWorker.enabled to be true"
+ );
+}
+
+// Return the name of the enum corresponding to the worker's state (ex: "STATE_ACTIVATED")
+// because nsIServiceWorkerInfo doesn't currently provide a comparable string-returning getter.
+function getServiceWorkerState(workerInfo) {
+ const map = Object.keys(workerInfo)
+ .filter(k => k.startsWith("STATE_"))
+ .reduce((map, name) => {
+ map.set(workerInfo[name], name);
+ return map;
+ }, new Map());
+ return map.has(workerInfo.state)
+ ? map.get(workerInfo.state)
+ : "state: ${workerInfo.state}";
+}
+
+function getServiceWorkerInfo(swRegInfo) {
+ const { evaluatingWorker, installingWorker, waitingWorker, activeWorker } =
+ swRegInfo;
+ return evaluatingWorker || installingWorker || waitingWorker || activeWorker;
+}
+
+async function waitForServiceWorkerTerminated(swRegInfo) {
+ info(`Wait all ${swRegInfo.scope} workers to be terminated`);
+
+ try {
+ await BrowserTestUtils.waitForCondition(
+ () => !getServiceWorkerInfo(swRegInfo)
+ );
+ } catch (err) {
+ const workerInfo = getServiceWorkerInfo(swRegInfo);
+ if (workerInfo) {
+ ok(
+ false,
+ `Error while waiting for workers for scope ${swRegInfo.scope} to be terminated. ` +
+ `Found a worker in state: ${getServiceWorkerState(workerInfo)}`
+ );
+ return;
+ }
+
+ throw err;
+ }
+}
+
+function getBackgroundServiceWorkerRegistration(extension) {
+ const policy = WebExtensionPolicy.getByHostname(extension.uuid);
+ const expectedSWScope = policy.getURL("/");
+ const expectedScriptURL = policy.extension.backgroundWorkerScript || "";
+
+ ok(
+ expectedScriptURL.startsWith(expectedSWScope),
+ `Extension does include a valid background.service_worker: ${expectedScriptURL}`
+ );
+
+ const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService(
+ Ci.nsIServiceWorkerManager
+ );
+
+ let swReg;
+ let regs = swm.getAllRegistrations();
+
+ for (let i = 0; i < regs.length; i++) {
+ let reg = regs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo);
+ if (reg.scriptSpec === expectedScriptURL) {
+ swReg = reg;
+ break;
+ }
+ }
+
+ ok(swReg, `Found service worker registration for ${expectedScriptURL}`);
+
+ is(
+ swReg.scope,
+ expectedSWScope,
+ "The extension background worker registration has the expected scope URL"
+ );
+
+ return swReg;
+}
+
+async function waitForServiceWorkerRegistrationsRemoved(extension) {
+ info(`Wait ${extension.id} service worker registration to be deleted`);
+ const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService(
+ Ci.nsIServiceWorkerManager
+ );
+ let baseURI = Services.io.newURI(`moz-extension://${extension.uuid}/`);
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ baseURI,
+ {}
+ );
+
+ await BrowserTestUtils.waitForCondition(() => {
+ let regs = swm.getAllRegistrations();
+
+ for (let i = 0; i < regs.length; i++) {
+ let reg = regs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo);
+ if (principal.equals(reg.principal)) {
+ return false;
+ }
+ }
+
+ info(`All ${extension.id} service worker registrations are gone`);
+ return true;
+ }, `All ${extension.id} service worker registrations should be deleted`);
+}
diff --git a/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json
new file mode 100644
index 0000000000..38a5c3f027
--- /dev/null
+++ b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json
@@ -0,0 +1,11 @@
+{
+ "manifest_version": 2,
+ "name": "Test Extension with Background Service Worker",
+ "version": "1",
+ "browser_specific_settings": {
+ "gecko": { "id": "extension-with-bg-sw@test" }
+ },
+ "background": {
+ "service_worker": "sw.js"
+ }
+}
diff --git a/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js
new file mode 100644
index 0000000000..2282e6a64b
--- /dev/null
+++ b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js
@@ -0,0 +1,3 @@
+"use strict";
+
+dump("extension-with-bg-sw: sw.js loaded");
diff --git a/toolkit/components/extensions/test/marionette/manifest.ini b/toolkit/components/extensions/test/marionette/manifest.ini
new file mode 100644
index 0000000000..8073096b9e
--- /dev/null
+++ b/toolkit/components/extensions/test/marionette/manifest.ini
@@ -0,0 +1,2 @@
+[test_extension_serviceworkers_purged_on_pref_disabled.py]
+[test_temporary_extension_serviceworkers_not_persisted.py]
diff --git a/toolkit/components/extensions/test/marionette/service_worker_testutils.py b/toolkit/components/extensions/test/marionette/service_worker_testutils.py
new file mode 100644
index 0000000000..b1fda926c0
--- /dev/null
+++ b/toolkit/components/extensions/test/marionette/service_worker_testutils.py
@@ -0,0 +1,48 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from marionette_harness import MarionetteTestCase
+
+EXT_ID = "extension-with-bg-sw@test"
+EXT_DIR_PATH = "extension-with-bg-sw"
+PREF_BG_SW_ENABLED = "extensions.backgroundServiceWorker.enabled"
+PREF_PERSIST_TEMP_ADDONS = (
+ "dom.serviceWorkers.testing.persistTemporarilyInstalledAddons"
+)
+
+
+class MarionetteServiceWorkerTestCase(MarionetteTestCase):
+ def get_extension_url(self, path="/"):
+ with self.marionette.using_context("chrome"):
+ return self.marionette.execute_script(
+ """
+ let policy = WebExtensionPolicy.getByID(arguments[0]);
+ return policy.getURL(arguments[1])
+ """,
+ script_args=(self.test_extension_id, path),
+ )
+
+ @property
+ def is_extension_service_worker_registered(self):
+ with self.marionette.using_context("chrome"):
+ return self.marionette.execute_script(
+ """
+ let serviceWorkerManager = Cc["@mozilla.org/serviceworkers/manager;1"].getService(
+ Ci.nsIServiceWorkerManager
+ );
+
+ let serviceWorkers = serviceWorkerManager.getAllRegistrations();
+ for (let i = 0; i < serviceWorkers.length; i++) {
+ let sw = serviceWorkers.queryElementAt(
+ i,
+ Ci.nsIServiceWorkerRegistrationInfo
+ );
+ if (sw.scope == arguments[0]) {
+ return true;
+ }
+ }
+ return false;
+ """,
+ script_args=(self.test_extension_base_url,),
+ )
diff --git a/toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py b/toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py
new file mode 100644
index 0000000000..ff2184c692
--- /dev/null
+++ b/toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py
@@ -0,0 +1,56 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.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 os
+import sys
+
+from marionette_driver import Wait
+from marionette_driver.addons import Addons
+
+# Add this directory to the import path.
+sys.path.append(os.path.dirname(__file__))
+
+from service_worker_testutils import (
+ EXT_DIR_PATH,
+ EXT_ID,
+ PREF_BG_SW_ENABLED,
+ PREF_PERSIST_TEMP_ADDONS,
+ MarionetteServiceWorkerTestCase,
+)
+
+
+class PurgeExtensionServiceWorkersOnPrefDisabled(MarionetteServiceWorkerTestCase):
+ def setUp(self):
+ super(PurgeExtensionServiceWorkersOnPrefDisabled, self).setUp()
+ self.test_extension_id = EXT_ID
+ # Flip the "mirror: once" pref and restart Firefox to be able
+ # to run the extension successfully.
+ self.marionette.set_pref(PREF_BG_SW_ENABLED, True)
+ self.marionette.set_pref(PREF_PERSIST_TEMP_ADDONS, True)
+ self.marionette.restart(in_app=True)
+
+ def tearDown(self):
+ self.marionette.restart(in_app=False, clean=True)
+ super(PurgeExtensionServiceWorkersOnPrefDisabled, self).tearDown()
+
+ def test_unregistering_service_worker_when_clearing_data(self):
+ self.install_extension_with_service_worker()
+
+ # Flip the pref to false and restart again to verify that the
+ # service worker registration has been removed as expected.
+ self.marionette.set_pref(PREF_BG_SW_ENABLED, False)
+ self.marionette.restart(in_app=True)
+ self.assertFalse(self.is_extension_service_worker_registered)
+
+ def install_extension_with_service_worker(self):
+ addons = Addons(self.marionette)
+ test_extension_path = os.path.join(
+ os.path.dirname(self.filepath), "data", EXT_DIR_PATH
+ )
+ addons.install(test_extension_path, temp=True)
+ self.test_extension_base_url = self.get_extension_url()
+ Wait(self.marionette).until(
+ lambda _: self.is_extension_service_worker_registered,
+ message="Wait the extension service worker to be registered",
+ )
diff --git a/toolkit/components/extensions/test/marionette/test_temporary_extension_serviceworkers_not_persisted.py b/toolkit/components/extensions/test/marionette/test_temporary_extension_serviceworkers_not_persisted.py
new file mode 100644
index 0000000000..57c0696385
--- /dev/null
+++ b/toolkit/components/extensions/test/marionette/test_temporary_extension_serviceworkers_not_persisted.py
@@ -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/.
+
+import os
+import sys
+
+from marionette_driver import Wait
+from marionette_driver.addons import Addons
+
+# Add this directory to the import path.
+sys.path.append(os.path.dirname(__file__))
+
+from service_worker_testutils import (
+ EXT_DIR_PATH,
+ EXT_ID,
+ PREF_BG_SW_ENABLED,
+ MarionetteServiceWorkerTestCase,
+)
+
+
+class TemporarilyInstalledAddonServiceWorkerNotPersisted(
+ MarionetteServiceWorkerTestCase
+):
+ def setUp(self):
+ super(TemporarilyInstalledAddonServiceWorkerNotPersisted, self).setUp()
+ self.test_extension_id = EXT_ID
+ # Flip the "mirror: once" pref and restart Firefox to be able
+ # to run the extension successfully.
+ self.marionette.set_pref(PREF_BG_SW_ENABLED, True)
+ self.marionette.restart(in_app=True)
+
+ def tearDown(self):
+ self.marionette.restart(in_app=False, clean=True)
+ super(TemporarilyInstalledAddonServiceWorkerNotPersisted, self).tearDown()
+
+ def test_temporarily_installed_addon_serviceWorkers_not_persisted(self):
+ self.install_temporary_extension_with_service_worker()
+ # Make sure the extension worker registration is persisted
+ # across restarts when the pref stays set to true.
+ self.marionette.restart(in_app=True)
+ self.assertFalse(self.is_extension_service_worker_registered)
+
+ def install_temporary_extension_with_service_worker(self):
+ addons = Addons(self.marionette)
+ test_extension_path = os.path.join(
+ os.path.dirname(self.filepath), "data", EXT_DIR_PATH
+ )
+ addons.install(test_extension_path, temp=True)
+ self.test_extension_base_url = self.get_extension_url()
+ Wait(self.marionette).until(
+ lambda _: self.is_extension_service_worker_registered,
+ message="Wait the extension service worker to be registered",
+ )
diff --git a/toolkit/components/extensions/test/mochitest/.eslintrc.js b/toolkit/components/extensions/test/mochitest/.eslintrc.js
new file mode 100644
index 0000000000..a776405c9d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/.eslintrc.js
@@ -0,0 +1,12 @@
+"use strict";
+
+module.exports = {
+ env: {
+ browser: true,
+ webextensions: true,
+ },
+
+ rules: {
+ "no-shadow": 0,
+ },
+};
diff --git a/toolkit/components/extensions/test/mochitest/chrome.ini b/toolkit/components/extensions/test/mochitest/chrome.ini
new file mode 100644
index 0000000000..30781a8759
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/chrome.ini
@@ -0,0 +1,38 @@
+[DEFAULT]
+support-files =
+ chrome_cleanup_script.js
+ head.js
+ head_cookies.js
+ file_image_good.png
+ file_image_great.png
+ file_sample.html
+ file_with_images.html
+ webrequest_chromeworker.js
+ webrequest_test.sys.mjs
+prefs =
+ security.mixed_content.upgrade_display_content=false
+tags = webextensions in-process-webextensions
+
+# NO NEW TESTS. mochitest-chrome does not run under e10s, avoid adding new
+# tests here unless absolutely necessary.
+
+[test_chrome_ext_contentscript_data_uri.html]
+[test_chrome_ext_contentscript_telemetry.html]
+[test_chrome_ext_contentscript_unrecognizedprop_warning.html]
+[test_chrome_ext_downloads_open.html]
+[test_chrome_ext_downloads_saveAs.html]
+skip-if = (verify && !debug && (os == 'win')) || (os == 'android') || (os == 'win' && os_version == '10.0') # Bug 1695612
+[test_chrome_ext_downloads_uniquify.html]
+skip-if = os == 'win' && os_version == '10.0' # Bug 1695612
+[test_chrome_ext_permissions.html]
+skip-if = os == 'android' # Bug 1350559
+[test_chrome_ext_svg_context_fill.html]
+[test_chrome_ext_trackingprotection.html]
+[test_chrome_ext_webnavigation_resolved_urls.html]
+[test_chrome_ext_webrequest_background_events.html]
+[test_chrome_ext_webrequest_host_permissions.html]
+skip-if = verify
+[test_chrome_ext_webrequest_mozextension.html]
+skip-if = true # Bug 1404172
+[test_chrome_native_messaging_paths.html]
+skip-if = os != "mac" && os != "linux"
diff --git a/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js b/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js
new file mode 100644
index 0000000000..9afa95f302
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js
@@ -0,0 +1,65 @@
+/* eslint-env mozilla/chrome-script */
+
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+let listener = msg => {
+ void (msg instanceof Ci.nsIConsoleMessage);
+ dump(`Console message: ${msg}\n`);
+};
+
+Services.console.registerListener(listener);
+
+let getBrowserApp, getTabBrowser;
+if (AppConstants.MOZ_BUILD_APP === "mobile/android") {
+ getBrowserApp = win => win.BrowserApp;
+ getTabBrowser = tab => tab.browser;
+} else {
+ getBrowserApp = win => win.gBrowser;
+ getTabBrowser = tab => tab.linkedBrowser;
+}
+
+function* iterBrowserWindows() {
+ for (let win of Services.wm.getEnumerator("navigator:browser")) {
+ if (!win.closed && getBrowserApp(win)) {
+ yield win;
+ }
+ }
+}
+
+let initialTabs = new Map();
+for (let win of iterBrowserWindows()) {
+ initialTabs.set(win, new Set(getBrowserApp(win).tabs));
+}
+
+addMessageListener("check-cleanup", extensionId => {
+ Services.console.unregisterListener(listener);
+
+ let results = {
+ extraWindows: [],
+ extraTabs: [],
+ };
+
+ for (let win of iterBrowserWindows()) {
+ if (initialTabs.has(win)) {
+ let tabs = initialTabs.get(win);
+
+ for (let tab of getBrowserApp(win).tabs) {
+ if (!tabs.has(tab)) {
+ results.extraTabs.push(getTabBrowser(tab).currentURI.spec);
+ }
+ }
+ } else {
+ results.extraWindows.push(
+ Array.from(win.gBrowser.tabs, tab => getTabBrowser(tab).currentURI.spec)
+ );
+ }
+ }
+
+ initialTabs = null;
+
+ sendAsyncMessage("cleanup-results", results);
+});
diff --git a/toolkit/components/extensions/test/mochitest/chrome_head.js b/toolkit/components/extensions/test/mochitest/chrome_head.js
new file mode 100644
index 0000000000..3918c74e44
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/chrome_head.js
@@ -0,0 +1 @@
+"use strict";
diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html
new file mode 100644
index 0000000000..663ebc6112
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<iframe src="file_WebNavigation_page2.html" width="200" height="200"></iframe>
+
+<form>
+</form>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html
new file mode 100644
index 0000000000..cc1acc83d6
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html
@@ -0,0 +1,7 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html
new file mode 100644
index 0000000000..a0a26a2e9d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<a id="elt" href="file_WebNavigation_page3.html#ref">click me</a>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html b/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html
new file mode 100644
index 0000000000..24c7a42986
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+<script>
+"use strict";
+</script>
+</head>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_contains_iframe.html b/toolkit/components/extensions/test/mochitest/file_contains_iframe.html
new file mode 100644
index 0000000000..e905b5a224
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_contains_iframe.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+<title>file contains iframe</title>
+</head>
+<body>
+
+<iframe src="//example.org/tests/toolkit/components/extensions/test/mochitest/file_contains_img.html">
+</iframe>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_contains_img.html b/toolkit/components/extensions/test/mochitest/file_contains_img.html
new file mode 100644
index 0000000000..2b0c3137d6
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_contains_img.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+<title>file contains img</title>
+</head>
+<body>
+
+<img src="file_image_good.png"/>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html
new file mode 100644
index 0000000000..6c1675cb47
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <iframe id="emptyframe"></iframe>
+ <iframe id="regularframe" src="http://test1.example.com/"></iframe>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html
new file mode 100644
index 0000000000..3b102b3d67
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <iframe srcdoc="<iframe src='http://test1.example.com/'&gt;</iframe&gt;"></iframe>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html b/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html
new file mode 100644
index 0000000000..670bad1360
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <iframe id="frame" src="https://test2.example.com/"></iframe>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_green.html b/toolkit/components/extensions/test/mochitest/file_green.html
new file mode 100644
index 0000000000..20755c5b56
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_green.html
@@ -0,0 +1,3 @@
+<meta charset=utf-8>
+<title>Super green test page</title>
+<body style="background: #0f0">
diff --git a/toolkit/components/extensions/test/mochitest/file_green_blue.html b/toolkit/components/extensions/test/mochitest/file_green_blue.html
new file mode 100644
index 0000000000..9266b637ba
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_green_blue.html
@@ -0,0 +1,16 @@
+<meta charset=utf-8>
+<title>Upper square green, rest blue</title>
+<style>
+ div {
+ position: absolute;
+ width: 50vw;
+ height: 50vh;
+ top: 0;
+ left: 0;
+ background-color: lime;
+ }
+ :root {
+ background-color: blue;
+ }
+</style>
+<div></div>
diff --git a/toolkit/components/extensions/test/mochitest/file_image_bad.png b/toolkit/components/extensions/test/mochitest/file_image_bad.png
new file mode 100644
index 0000000000..4c3be50847
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_image_bad.png
Binary files differ
diff --git a/toolkit/components/extensions/test/mochitest/file_image_good.png b/toolkit/components/extensions/test/mochitest/file_image_good.png
new file mode 100644
index 0000000000..769c636340
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_image_good.png
Binary files differ
diff --git a/toolkit/components/extensions/test/mochitest/file_image_great.png b/toolkit/components/extensions/test/mochitest/file_image_great.png
new file mode 100644
index 0000000000..769c636340
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_image_great.png
Binary files differ
diff --git a/toolkit/components/extensions/test/mochitest/file_image_redirect.png b/toolkit/components/extensions/test/mochitest/file_image_redirect.png
new file mode 100644
index 0000000000..4c3be50847
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_image_redirect.png
Binary files differ
diff --git a/toolkit/components/extensions/test/mochitest/file_indexedDB.html b/toolkit/components/extensions/test/mochitest/file_indexedDB.html
new file mode 100644
index 0000000000..65b7e0ad2f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_indexedDB.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <script>
+"use strict";
+
+const objectStoreName = "Objects";
+
+let test = {key: 0, value: "test"};
+
+let request = indexedDB.open("WebExtensionTest", 1);
+request.onupgradeneeded = event => {
+ let db = event.target.result;
+ let objectStore = db.createObjectStore(objectStoreName,
+ {autoIncrement: 0});
+ request = objectStore.add(test.value, test.key);
+ request.onsuccess = event => {
+ db.close();
+ window.postMessage("indexedDBCreated", "*");
+ };
+};
+ </script>
+ </head>
+ <body>
+ This is a test page.
+ </body>
+<html>
diff --git a/toolkit/components/extensions/test/mochitest/file_mixed.html b/toolkit/components/extensions/test/mochitest/file_mixed.html
new file mode 100644
index 0000000000..f3c7dda580
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_mixed.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div id="test">Sample text</div>
+<img id="bad-image" src="http://example.com/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png" />
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html b/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html
new file mode 100644
index 0000000000..b8fda2369a
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html
@@ -0,0 +1,30 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>1450965 Skip Cors Check for Early WebExtention Redirects</title>
+</head>
+<body>
+ <pre id="c">
+ Fetching ...
+ </pre>
+ <script>
+ "use strict";
+ let c = document.querySelector("#c");
+ const channel = new BroadcastChannel("test_bus");
+ function l(t) { c.innerText += `${t}\n`; }
+
+ fetch("https://example.org/tests/toolkit/components/extensions/test/mochitest/file_cors_blocked.txt")
+ .then(r => r.text())
+ .then(t => {
+ // This Request should have been redirected to /file_sample.txt in
+ // onBeforeRequest. So the text should be 'Sample'
+ l(`Loaded: ${t}`);
+ channel.postMessage(t);
+ }).catch(e => {
+ // The Redirect Failed, most likly due to a CORS Error
+ l(`e`);
+ channel.postMessage(e.toString());
+ });
+ </script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html b/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html
new file mode 100644
index 0000000000..fe8e5bea44
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Bug 1434357: Allow Web Request API to redirect to data: URI</title>
+</head>
+<body>
+ <div id="testdiv">foo</div>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_remote_frame.html b/toolkit/components/extensions/test/mochitest/file_remote_frame.html
new file mode 100644
index 0000000000..f1b9240092
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_remote_frame.html
@@ -0,0 +1,20 @@
+<!DOCTYPE>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <script>
+ "use strict";
+ var response = {
+ tabs: false,
+ cookie: document.cookie,
+ };
+ try {
+ browser.tabs.create({url: "file_sample.html"});
+ response.tabs = true;
+ } catch (e) {
+ // ok
+ }
+ window.parent.postMessage(response, "*");
+ </script>
+ </head>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_sample.html b/toolkit/components/extensions/test/mochitest/file_sample.html
new file mode 100644
index 0000000000..aa1ef6e6f4
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_sample.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+<title>file sample</title>
+</head>
+<body>
+
+<div id="test">Sample text</div>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_sample.txt b/toolkit/components/extensions/test/mochitest/file_sample.txt
new file mode 100644
index 0000000000..c02cd532b1
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_sample.txt
@@ -0,0 +1 @@
+Sample \ No newline at end of file
diff --git a/toolkit/components/extensions/test/mochitest/file_sample.txt^headers^ b/toolkit/components/extensions/test/mochitest/file_sample.txt^headers^
new file mode 100644
index 0000000000..cb762eff80
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_sample.txt^headers^
@@ -0,0 +1 @@
+Access-Control-Allow-Origin: *
diff --git a/toolkit/components/extensions/test/mochitest/file_script_bad.js b/toolkit/components/extensions/test/mochitest/file_script_bad.js
new file mode 100644
index 0000000000..c425122c71
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_script_bad.js
@@ -0,0 +1,3 @@
+"use strict";
+
+window.failure = true;
diff --git a/toolkit/components/extensions/test/mochitest/file_script_good.js b/toolkit/components/extensions/test/mochitest/file_script_good.js
new file mode 100644
index 0000000000..14e959aa5c
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_script_good.js
@@ -0,0 +1,12 @@
+"use strict";
+
+window.success = window.success ? window.success + 1 : 1;
+
+{
+ let scripts = document.getElementsByTagName("script");
+ let url = new URL(scripts[scripts.length - 1].src);
+ let flag = url.searchParams.get("q");
+ if (flag) {
+ window.postMessage(flag, "*");
+ }
+}
diff --git a/toolkit/components/extensions/test/mochitest/file_script_redirect.js b/toolkit/components/extensions/test/mochitest/file_script_redirect.js
new file mode 100644
index 0000000000..c425122c71
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_script_redirect.js
@@ -0,0 +1,3 @@
+"use strict";
+
+window.failure = true;
diff --git a/toolkit/components/extensions/test/mochitest/file_script_xhr.js b/toolkit/components/extensions/test/mochitest/file_script_xhr.js
new file mode 100644
index 0000000000..ad01f74253
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_script_xhr.js
@@ -0,0 +1,9 @@
+"use strict";
+
+var request = new XMLHttpRequest();
+request.open(
+ "get",
+ "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/xhr_resource",
+ false
+);
+request.send();
diff --git a/toolkit/components/extensions/test/mochitest/file_serviceWorker.html b/toolkit/components/extensions/test/mochitest/file_serviceWorker.html
new file mode 100644
index 0000000000..d2b99769cc
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_serviceWorker.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <script>
+ "use strict";
+
+ navigator.serviceWorker.register("serviceWorker.js").then(() => {
+ window.postMessage("serviceWorkerRegistered", "*");
+ });
+ </script>
+ </head>
+ <body>
+ This is a test page.
+ </body>
+<html>
diff --git a/toolkit/components/extensions/test/mochitest/file_simple_iframe_worker.html b/toolkit/components/extensions/test/mochitest/file_simple_iframe_worker.html
new file mode 100644
index 0000000000..2ecc24e648
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_simple_iframe_worker.html
@@ -0,0 +1,26 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script type="application/javascript">
+"use strict";
+
+fetch("file_simple_iframe.txt");
+const worker = new Worker("file_simple_worker.js?iniframe=true");
+worker.onmessage = (msg) => {
+ worker.postMessage("file_simple_iframe_worker.txt");
+}
+
+const sharedworker = new SharedWorker("file_simple_sharedworker.js?iniframe=true");
+sharedworker.port.onmessage = (msg) => {
+ sharedworker.port.postMessage("file_simple_iframe_sharedworker.txt");
+}
+sharedworker.port.start();
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html
new file mode 100644
index 0000000000..909a1f9e36
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html
@@ -0,0 +1,23 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+"use strict";
+
+let req = new XMLHttpRequest();
+req.open("GET", "/xhr_sandboxed");
+req.send();
+
+let sandbox = document.createElement("iframe");
+sandbox.setAttribute("sandbox", "allow-scripts");
+sandbox.setAttribute("src", "file_simple_sandboxed_subframe.html");
+document.documentElement.appendChild(sandbox);
+</script>
+<img src="file_image_great.png"/>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html
new file mode 100644
index 0000000000..a0a437d0eb
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_simple_sharedworker.js b/toolkit/components/extensions/test/mochitest/file_simple_sharedworker.js
new file mode 100644
index 0000000000..e8776216f1
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_simple_sharedworker.js
@@ -0,0 +1,11 @@
+"use strict";
+
+self.onconnect = async evt => {
+ const port = evt.ports[0];
+ port.onmessage = async message => {
+ await fetch(message.data);
+ self.close();
+ };
+ port.start();
+ port.postMessage("loaded");
+};
diff --git a/toolkit/components/extensions/test/mochitest/file_simple_webrequest_worker.html b/toolkit/components/extensions/test/mochitest/file_simple_webrequest_worker.html
new file mode 100644
index 0000000000..a90c4509be
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_simple_webrequest_worker.html
@@ -0,0 +1,28 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script type="application/javascript">
+"use strict";
+
+fetch("file_simple_toplevel.txt");
+const worker = new Worker("file_simple_worker.js");
+worker.onmessage = (msg) => {
+ worker.postMessage("file_simple_worker.txt");
+}
+
+const sharedworker = new SharedWorker("file_simple_sharedworker.js");
+sharedworker.port.onmessage = (msg) => {
+ dump(`postMessage to sharedworker\n`);
+ sharedworker.port.postMessage("file_simple_sharedworker.txt");
+}
+sharedworker.port.start();
+
+</script>
+<iframe src="file_simple_iframe_worker.html"></iframe>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_simple_worker.js b/toolkit/components/extensions/test/mochitest/file_simple_worker.js
new file mode 100644
index 0000000000..9638a8e9c2
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_simple_worker.js
@@ -0,0 +1,8 @@
+"use strict";
+
+self.onmessage = async message => {
+ await fetch(message.data);
+ self.close();
+};
+
+self.postMessage("loaded");
diff --git a/toolkit/components/extensions/test/mochitest/file_simple_xhr.html b/toolkit/components/extensions/test/mochitest/file_simple_xhr.html
new file mode 100644
index 0000000000..1b43f804d9
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+"use strict";
+
+let req = new XMLHttpRequest();
+req.open("GET", "https://example.org/example.txt");
+req.send();
+</script>
+<img src="file_image_good.png"/>
+<iframe src="file_simple_xhr_frame.html"/>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html
new file mode 100644
index 0000000000..7f38247ac0
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+"use strict";
+
+let req = new XMLHttpRequest();
+req.open("GET", "/xhr_resource");
+req.send();
+</script>
+<img src="file_image_bad.png"/>
+<iframe src="file_simple_xhr_frame2.html"/>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html
new file mode 100644
index 0000000000..6174a0b402
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html
@@ -0,0 +1,23 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+"use strict";
+
+let req = new XMLHttpRequest();
+req.open("GET", "/xhr_resource_2");
+req.send();
+
+let sandbox = document.createElement("iframe");
+sandbox.setAttribute("sandbox", "allow-scripts");
+sandbox.setAttribute("src", "file_simple_sandboxed_frame.html");
+document.documentElement.appendChild(sandbox);
+</script>
+<img src="file_image_redirect.png"/>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_slowed_document.sjs b/toolkit/components/extensions/test/mochitest/file_slowed_document.sjs
new file mode 100644
index 0000000000..8c42fcc966
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_slowed_document.sjs
@@ -0,0 +1,49 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */
+"use strict";
+
+// This script slows the load of an HTML document so that we can reliably test
+// all phases of the load cycle supported by the extension API.
+
+/* eslint-disable no-unused-vars */
+
+const URL = "file_slowed_document.sjs";
+
+const DELAY = 2 * 1000; // Delay two seconds before completing the request.
+
+let nsTimer = Components.Constructor(
+ "@mozilla.org/timer;1",
+ "nsITimer",
+ "initWithCallback"
+);
+
+let timer;
+
+function handleRequest(request, response) {
+ response.processAsync();
+
+ response.setHeader("Content-Type", "text/html", false);
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.write(`<!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title></title>
+ </head>
+ <body>
+ `);
+
+ // Note: We need to store a reference to the timer to prevent it from being
+ // canceled when it's GCed.
+ timer = new nsTimer(
+ () => {
+ if (request.queryString.includes("with-iframe")) {
+ response.write(`<iframe src="${URL}?r=${Math.random()}"></iframe>`);
+ }
+ response.write(`</body></html>`);
+ response.finish();
+ },
+ DELAY,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+}
diff --git a/toolkit/components/extensions/test/mochitest/file_streamfilter.txt b/toolkit/components/extensions/test/mochitest/file_streamfilter.txt
new file mode 100644
index 0000000000..56cdd85e1d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_streamfilter.txt
@@ -0,0 +1 @@
+Middle
diff --git a/toolkit/components/extensions/test/mochitest/file_style_bad.css b/toolkit/components/extensions/test/mochitest/file_style_bad.css
new file mode 100644
index 0000000000..8dbc8dc7a4
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_style_bad.css
@@ -0,0 +1,3 @@
+#test {
+ color: green !important;
+}
diff --git a/toolkit/components/extensions/test/mochitest/file_style_good.css b/toolkit/components/extensions/test/mochitest/file_style_good.css
new file mode 100644
index 0000000000..46f9774b5f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_style_good.css
@@ -0,0 +1,3 @@
+#test {
+ color: red;
+}
diff --git a/toolkit/components/extensions/test/mochitest/file_style_redirect.css b/toolkit/components/extensions/test/mochitest/file_style_redirect.css
new file mode 100644
index 0000000000..8dbc8dc7a4
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_style_redirect.css
@@ -0,0 +1,3 @@
+#test {
+ color: green !important;
+}
diff --git a/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html
new file mode 100644
index 0000000000..63f503ad3c
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+ <title>The Title</title>
+</head>
+<body>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html
new file mode 100644
index 0000000000..87ac7a2f64
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+ <title>Another Title</title>
+ <link href="file_image_great.png" rel="icon" type="image/png" />
+</head>
+<body>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_third_party.html b/toolkit/components/extensions/test/mochitest/file_third_party.html
new file mode 100644
index 0000000000..fc5a326297
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_third_party.html
@@ -0,0 +1,21 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+
+"use strict"
+
+let url = new URL(location);
+let img = new Image();
+img.src = `http://${url.searchParams.get("domain")}/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png`;
+document.body.appendChild(img);
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html b/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html
new file mode 100644
index 0000000000..6ebd54d9a3
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html
@@ -0,0 +1,9 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body style="background: #ff9">
+ &nbsp;
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html
new file mode 100644
index 0000000000..cba3043f71
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+
+<html>
+ <head>
+ <meta http-equiv="refresh" content="1;dummy_page.html">
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html
new file mode 100644
index 0000000000..c5b436979f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html
@@ -0,0 +1,8 @@
+<!DOCTYPE HTML>
+
+<html>
+ <head>
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^
new file mode 100644
index 0000000000..574a392a15
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^
@@ -0,0 +1 @@
+Refresh: 1;url=dummy_page.html
diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html
new file mode 100644
index 0000000000..d360bcbb13
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<iframe src="file_webNavigation_clientRedirect.html" width="200" height="200"></iframe>
+
+<form>
+</form>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html
new file mode 100644
index 0000000000..06dbd43741
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<iframe src="redirection.sjs" width="200" height="200"></iframe>
+
+<form>
+</form>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html
new file mode 100644
index 0000000000..307990714b
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<iframe src="file_webNavigation_manualSubframe_page1.html" width="200" height="200"></iframe>
+
+<form>
+</form>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html
new file mode 100644
index 0000000000..55bb7aa6ae
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+
+<html>
+ <body>
+ <h1>page1</h1>
+ <a href="file_webNavigation_manualSubframe_page2.html">page2</a>
+ </body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html
new file mode 100644
index 0000000000..8f589f8bbd
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html
@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+
+<html>
+ <body>
+ <h1>page2</h1>
+ </body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_with_about_blank.html b/toolkit/components/extensions/test/mochitest/file_with_about_blank.html
new file mode 100644
index 0000000000..af51c2e52a
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_with_about_blank.html
@@ -0,0 +1,10 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <iframe id="a_b" src="about:blank"></iframe>
+ <iframe srcdoc="galactica actual" src="adama"></iframe>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_with_images.html b/toolkit/components/extensions/test/mochitest/file_with_images.html
new file mode 100644
index 0000000000..6a3c090be2
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_with_images.html
@@ -0,0 +1,10 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <img src="https://example.com/chrome/toolkit/components/extensions/test/mochitest/file_image_good.png">
+ <img src="http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_image_great.png">
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_with_subframes_and_embed.html b/toolkit/components/extensions/test/mochitest/file_with_subframes_and_embed.html
new file mode 100644
index 0000000000..348c51f16c
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_with_subframes_and_embed.html
@@ -0,0 +1,21 @@
+<!DOCTYPE HTML>
+<meta charset="utf-8">
+
+Load a bunch of iframes with subframes.
+<p>
+<iframe src="file_contains_iframe.html"></iframe>
+<iframe src="file_WebNavigation_page1.html"></iframe>
+<iframe src="file_with_xorigin_frame.html"></iframe>
+
+<p>
+Load an embed frame.
+<p>
+<embed type="text/html" src="file_sample.html"></embed>
+
+<p>
+And an object.
+<p>
+<object type="text/html" data="file_contains_img.html"></embed>
+
+<p>
+Done.
diff --git a/toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html b/toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html
new file mode 100644
index 0000000000..25c60df078
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html
@@ -0,0 +1,6 @@
+<!DOCTYPE HTML>
+<meta charset="utf-8">
+
+<img src="file_image_great.png"/>
+Load a cross-origin iframe from example.net <p>
+<iframe src="https://example.net/tests/toolkit/components/extensions/test/mochitest/file_sample.html"></iframe>
diff --git a/toolkit/components/extensions/test/mochitest/head.js b/toolkit/components/extensions/test/mochitest/head.js
new file mode 100644
index 0000000000..ad8fb2052f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/head.js
@@ -0,0 +1,117 @@
+"use strict";
+
+/* exported AppConstants, Assert, AppTestDelegate */
+
+var { AppConstants } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { AppTestDelegate } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://specialpowers/AppTestDelegate.sys.mjs"
+);
+
+{
+ let chromeScript = SpecialPowers.loadChromeScript(
+ SimpleTest.getTestFileURL("chrome_cleanup_script.js")
+ );
+
+ SimpleTest.registerCleanupFunction(async () => {
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ chromeScript.sendAsyncMessage("check-cleanup");
+
+ let results = await chromeScript.promiseOneMessage("cleanup-results");
+ chromeScript.destroy();
+
+ if (results.extraWindows.length || results.extraTabs.length) {
+ ok(
+ false,
+ `Test left extra windows or tabs: ${JSON.stringify(results)}\n`
+ );
+ }
+ });
+}
+
+let Assert = {
+ // Cut-down version based on Assert.sys.mjs. Only supports regexp and objects as
+ // the expected variables.
+ rejects(promise, expected, msg) {
+ return promise.then(
+ () => {
+ ok(false, msg);
+ },
+ actual => {
+ let matched = false;
+ if (Object.prototype.toString.call(expected) == "[object RegExp]") {
+ if (expected.test(actual)) {
+ matched = true;
+ }
+ } else if (actual instanceof expected) {
+ matched = true;
+ }
+
+ if (matched) {
+ ok(true, msg);
+ } else {
+ ok(false, `Unexpected exception for "${msg}": ${actual}`);
+ }
+ }
+ );
+ },
+};
+
+/* exported waitForLoad */
+
+function waitForLoad(win) {
+ return new Promise(resolve => {
+ win.addEventListener(
+ "load",
+ function () {
+ resolve();
+ },
+ { capture: true, once: true }
+ );
+ });
+}
+
+/* exported loadChromeScript */
+function loadChromeScript(fn) {
+ let wrapper = `
+(${fn.toString()})();`;
+
+ return SpecialPowers.loadChromeScript(new Function(wrapper));
+}
+
+/* exported consoleMonitor */
+let consoleMonitor = {
+ start(messages) {
+ this.chromeScript = SpecialPowers.loadChromeScript(
+ SimpleTest.getTestFileURL("mochitest_console.js")
+ );
+ this.chromeScript.sendAsyncMessage("consoleStart", messages);
+ },
+
+ async finished() {
+ let done = this.chromeScript.promiseOneMessage("consoleDone").then(done => {
+ this.chromeScript.destroy();
+ return done;
+ });
+ this.chromeScript.sendAsyncMessage("waitForConsole");
+ let test = await done;
+ ok(test.ok, test.message);
+ },
+};
+/* exported waitForState */
+
+function waitForState(sw, state) {
+ return new Promise(resolve => {
+ if (sw.state === state) {
+ return resolve();
+ }
+ sw.addEventListener("statechange", function onStateChange() {
+ if (sw.state === state) {
+ sw.removeEventListener("statechange", onStateChange);
+ resolve();
+ }
+ });
+ });
+}
diff --git a/toolkit/components/extensions/test/mochitest/head_cookies.js b/toolkit/components/extensions/test/mochitest/head_cookies.js
new file mode 100644
index 0000000000..610c800c94
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/head_cookies.js
@@ -0,0 +1,287 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* exported testCookies */
+/* import-globals-from head.js */
+
+async function testCookies(options) {
+ // Changing the options object is a bit of a hack, but it allows us to easily
+ // pass an expiration date to the background script.
+ options.expiry = Date.now() / 1000 + 3600;
+
+ async function background(backgroundOptions) {
+ // Ask the parent scope to change some cookies we may or may not have
+ // permission for.
+ let awaitChanges = new Promise(resolve => {
+ browser.test.onMessage.addListener(msg => {
+ browser.test.assertEq("cookies-changed", msg, "browser.test.onMessage");
+ resolve();
+ });
+ });
+
+ let changed = [];
+ browser.cookies.onChanged.addListener(event => {
+ changed.push(`${event.cookie.name}:${event.cause}`);
+ });
+ browser.test.sendMessage("change-cookies");
+
+ // Try to access some cookies in various ways.
+ let { url, domain, secure } = backgroundOptions;
+
+ let failures = 0;
+ let tallyFailure = error => {
+ failures++;
+ };
+
+ try {
+ await awaitChanges;
+
+ let cookie = await browser.cookies.get({ url, name: "foo" });
+ browser.test.assertEq(
+ backgroundOptions.shouldPass,
+ cookie != null,
+ "should pass == get cookie"
+ );
+
+ let cookies = await browser.cookies.getAll({ domain });
+ if (backgroundOptions.shouldPass) {
+ browser.test.assertEq(2, cookies.length, "expected number of cookies");
+ } else {
+ browser.test.assertEq(0, cookies.length, "expected number of cookies");
+ }
+
+ await Promise.all([
+ browser.cookies
+ .set({
+ url,
+ domain,
+ secure,
+ name: "foo",
+ value: "baz",
+ expirationDate: backgroundOptions.expiry,
+ })
+ .catch(tallyFailure),
+ browser.cookies
+ .set({
+ url,
+ domain,
+ secure,
+ name: "bar",
+ value: "quux",
+ expirationDate: backgroundOptions.expiry,
+ })
+ .catch(tallyFailure),
+ browser.cookies.remove({ url, name: "deleted" }),
+ ]);
+
+ if (backgroundOptions.shouldPass) {
+ // The order of eviction events isn't guaranteed, so just check that
+ // it's there somewhere.
+ let evicted = changed.indexOf("evicted:evicted");
+ if (evicted < 0) {
+ browser.test.fail("got no eviction event");
+ } else {
+ browser.test.succeed("got eviction event");
+ changed.splice(evicted, 1);
+ }
+
+ browser.test.assertEq(
+ "x:explicit,x:overwrite,x:explicit,x:explicit,foo:overwrite,foo:explicit,bar:explicit,deleted:explicit",
+ changed.join(","),
+ "expected changes"
+ );
+ } else {
+ browser.test.assertEq("", changed.join(","), "expected no changes");
+ }
+
+ if (!(backgroundOptions.shouldPass || backgroundOptions.shouldWrite)) {
+ browser.test.assertEq(2, failures, "Expected failures");
+ } else {
+ browser.test.assertEq(0, failures, "Expected no failures");
+ }
+
+ browser.test.notifyPass("cookie-permissions");
+ } catch (error) {
+ browser.test.fail(`Error: ${error} :: ${error.stack}`);
+ browser.test.notifyFail("cookie-permissions");
+ }
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: options.permissions,
+ },
+
+ background: `(${background})(${JSON.stringify(options)})`,
+ });
+
+ let stepOne = loadChromeScript(() => {
+ const { addMessageListener, sendAsyncMessage } = this;
+ addMessageListener("options", options => {
+ let domain = options.domain.replace(/^\.?/, ".");
+ // This will be evicted after we add a fourth cookie.
+ Services.cookies.add(
+ domain,
+ "/",
+ "evicted",
+ "bar",
+ options.secure,
+ false,
+ false,
+ options.expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ options.url.startsWith("https")
+ ? Ci.nsICookie.SCHEME_HTTPS
+ : Ci.nsICookie.SCHEME_HTTP
+ );
+ // This will be modified by the background script.
+ Services.cookies.add(
+ domain,
+ "/",
+ "foo",
+ "bar",
+ options.secure,
+ false,
+ false,
+ options.expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ options.url.startsWith("https")
+ ? Ci.nsICookie.SCHEME_HTTPS
+ : Ci.nsICookie.SCHEME_HTTP
+ );
+ // This will be deleted by the background script.
+ Services.cookies.add(
+ domain,
+ "/",
+ "deleted",
+ "bar",
+ options.secure,
+ false,
+ false,
+ options.expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ options.url.startsWith("https")
+ ? Ci.nsICookie.SCHEME_HTTPS
+ : Ci.nsICookie.SCHEME_HTTP
+ );
+ sendAsyncMessage("done");
+ });
+ });
+ stepOne.sendAsyncMessage("options", options);
+ await stepOne.promiseOneMessage("done");
+ stepOne.destroy();
+
+ await extension.startup();
+
+ await extension.awaitMessage("change-cookies");
+
+ let stepTwo = loadChromeScript(() => {
+ const { addMessageListener, sendAsyncMessage } = this;
+ addMessageListener("options", options => {
+ let domain = options.domain.replace(/^\.?/, ".");
+
+ Services.cookies.add(
+ domain,
+ "/",
+ "x",
+ "y",
+ options.secure,
+ false,
+ false,
+ options.expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ options.url.startsWith("https")
+ ? Ci.nsICookie.SCHEME_HTTPS
+ : Ci.nsICookie.SCHEME_HTTP
+ );
+ Services.cookies.add(
+ domain,
+ "/",
+ "x",
+ "z",
+ options.secure,
+ false,
+ false,
+ options.expiry,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ options.url.startsWith("https")
+ ? Ci.nsICookie.SCHEME_HTTPS
+ : Ci.nsICookie.SCHEME_HTTP
+ );
+ Services.cookies.remove(domain, "x", "/", {});
+ sendAsyncMessage("done");
+ });
+ });
+ stepTwo.sendAsyncMessage("options", options);
+ await stepTwo.promiseOneMessage("done");
+ stepTwo.destroy();
+
+ extension.sendMessage("cookies-changed");
+
+ await extension.awaitFinish("cookie-permissions");
+ await extension.unload();
+
+ let stepThree = loadChromeScript(() => {
+ const { addMessageListener, sendAsyncMessage, assert } = this;
+ let cookieSvc = Services.cookies;
+
+ function getCookies(host) {
+ let cookies = [];
+ for (let cookie of cookieSvc.getCookiesFromHost(host, {})) {
+ cookies.push(cookie);
+ }
+ return cookies.sort((a, b) => a.name.localeCompare(b.name));
+ }
+
+ addMessageListener("options", options => {
+ let cookies = getCookies(options.domain);
+
+ if (options.shouldPass) {
+ assert.equal(cookies.length, 2, "expected two cookies for host");
+
+ assert.equal(cookies[0].name, "bar", "correct cookie name");
+ assert.equal(cookies[0].value, "quux", "correct cookie value");
+
+ assert.equal(cookies[1].name, "foo", "correct cookie name");
+ assert.equal(cookies[1].value, "baz", "correct cookie value");
+ } else if (options.shouldWrite) {
+ // Note: |shouldWrite| applies only when |shouldPass| is false.
+ // This is necessary because, unfortunately, websites (and therefore web
+ // extensions) are allowed to write some cookies which they're not allowed
+ // to read.
+ assert.equal(cookies.length, 3, "expected three cookies for host");
+
+ assert.equal(cookies[0].name, "bar", "correct cookie name");
+ assert.equal(cookies[0].value, "quux", "correct cookie value");
+
+ assert.equal(cookies[1].name, "deleted", "correct cookie name");
+
+ assert.equal(cookies[2].name, "foo", "correct cookie name");
+ assert.equal(cookies[2].value, "baz", "correct cookie value");
+ } else {
+ assert.equal(cookies.length, 2, "expected two cookies for host");
+
+ assert.equal(cookies[0].name, "deleted", "correct second cookie name");
+
+ assert.equal(cookies[1].name, "foo", "correct cookie name");
+ assert.equal(cookies[1].value, "bar", "correct cookie value");
+ }
+
+ for (let cookie of cookies) {
+ cookieSvc.remove(cookie.host, cookie.name, "/", {});
+ }
+ // Make sure we don't silently poison subsequent tests if something goes wrong.
+ assert.equal(getCookies(options.domain).length, 0, "cookies cleared");
+ sendAsyncMessage("done");
+ });
+ });
+ stepThree.sendAsyncMessage("options", options);
+ await stepThree.promiseOneMessage("done");
+ stepThree.destroy();
+}
diff --git a/toolkit/components/extensions/test/mochitest/head_notifications.js b/toolkit/components/extensions/test/mochitest/head_notifications.js
new file mode 100644
index 0000000000..bba3f59d49
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/head_notifications.js
@@ -0,0 +1,171 @@
+"use strict";
+
+/* exported MockAlertsService */
+
+function mockServicesChromeScript() {
+ /* eslint-env mozilla/chrome-script */
+
+ const MOCK_ALERTS_CID = Components.ID(
+ "{48068bc2-40ab-4904-8afd-4cdfb3a385f3}"
+ );
+ const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1";
+
+ const { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+ );
+ const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+
+ let activeNotifications = Object.create(null);
+
+ const mockAlertsService = {
+ showPersistentNotification: function (
+ persistentData,
+ alert,
+ alertListener
+ ) {
+ this.showAlert(alert, alertListener);
+ },
+
+ showAlert: function (alert, listener) {
+ activeNotifications[alert.name] = {
+ listener: listener,
+ cookie: alert.cookie,
+ title: alert.title,
+ };
+
+ // fake async alert show event
+ if (listener) {
+ setTimeout(function () {
+ listener.observe(null, "alertshow", alert.cookie);
+ }, 100);
+ }
+ },
+
+ showAlertNotification: function (
+ imageUrl,
+ title,
+ text,
+ textClickable,
+ cookie,
+ alertListener,
+ name
+ ) {
+ this.showAlert(
+ {
+ name: name,
+ cookie: cookie,
+ title: title,
+ },
+ alertListener
+ );
+ },
+
+ closeAlert: function (name) {
+ let alertNotification = activeNotifications[name];
+ if (alertNotification) {
+ if (alertNotification.listener) {
+ alertNotification.listener.observe(
+ null,
+ "alertfinished",
+ alertNotification.cookie
+ );
+ }
+ delete activeNotifications[name];
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIAlertsService"]),
+
+ createInstance: function (iid) {
+ return this.QueryInterface(iid);
+ },
+ };
+
+ registrar.registerFactory(
+ MOCK_ALERTS_CID,
+ "alerts service",
+ ALERTS_SERVICE_CONTRACT_ID,
+ mockAlertsService
+ );
+
+ function clickNotifications(doClose) {
+ // Until we need to close a specific notification, just click them all.
+ for (let [name, notification] of Object.entries(activeNotifications)) {
+ let { listener, cookie } = notification;
+ listener.observe(null, "alertclickcallback", cookie);
+ if (doClose) {
+ mockAlertsService.closeAlert(name);
+ }
+ }
+ }
+
+ function closeAllNotifications() {
+ for (let alertName of Object.keys(activeNotifications)) {
+ mockAlertsService.closeAlert(alertName);
+ }
+ }
+
+ const { addMessageListener, sendAsyncMessage } = this;
+
+ addMessageListener("mock-alert-service:unregister", () => {
+ closeAllNotifications();
+ activeNotifications = null;
+ registrar.unregisterFactory(MOCK_ALERTS_CID, mockAlertsService);
+ sendAsyncMessage("mock-alert-service:unregistered");
+ });
+
+ addMessageListener(
+ "mock-alert-service:click-notifications",
+ clickNotifications
+ );
+
+ addMessageListener(
+ "mock-alert-service:close-notifications",
+ closeAllNotifications
+ );
+
+ sendAsyncMessage("mock-alert-service:registered");
+}
+
+const MockAlertsService = {
+ async register() {
+ if (this._chromeScript) {
+ throw new Error("MockAlertsService already registered");
+ }
+ this._chromeScript = SpecialPowers.loadChromeScript(
+ mockServicesChromeScript
+ );
+ await this._chromeScript.promiseOneMessage("mock-alert-service:registered");
+ },
+ async unregister() {
+ if (!this._chromeScript) {
+ throw new Error("MockAlertsService not registered");
+ }
+ this._chromeScript.sendAsyncMessage("mock-alert-service:unregister");
+ return this._chromeScript
+ .promiseOneMessage("mock-alert-service:unregistered")
+ .then(() => {
+ this._chromeScript.destroy();
+ this._chromeScript = null;
+ });
+ },
+ async clickNotifications() {
+ // Most implementations of the nsIAlertsService automatically close upon click.
+ await this._chromeScript.sendAsyncMessage(
+ "mock-alert-service:click-notifications",
+ true
+ );
+ },
+ async clickNotificationsWithoutClose() {
+ // The implementation on macOS does not automatically close the notification.
+ await this._chromeScript.sendAsyncMessage(
+ "mock-alert-service:click-notifications",
+ false
+ );
+ },
+ async closeNotifications() {
+ await this._chromeScript.sendAsyncMessage(
+ "mock-alert-service:close-notifications"
+ );
+ },
+};
diff --git a/toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js b/toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js
new file mode 100644
index 0000000000..dfec90b3e0
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js
@@ -0,0 +1,45 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* exported checkSitePermissions */
+
+const { Services } = SpecialPowers;
+const { NetUtil } = SpecialPowers.ChromeUtils.import(
+ "resource://gre/modules/NetUtil.jsm"
+);
+
+function checkSitePermissions(uuid, expectedPermAction, assertMessage) {
+ if (!uuid) {
+ throw new Error(
+ "checkSitePermissions should not be called with an undefined uuid"
+ );
+ }
+
+ const baseURI = NetUtil.newURI(`moz-extension://${uuid}/`);
+ const principal = Services.scriptSecurityManager.createContentPrincipal(
+ baseURI,
+ {}
+ );
+
+ const sitePermissions = {
+ webextUnlimitedStorage: Services.perms.testPermissionFromPrincipal(
+ principal,
+ "WebExtensions-unlimitedStorage"
+ ),
+ persistentStorage: Services.perms.testPermissionFromPrincipal(
+ principal,
+ "persistent-storage"
+ ),
+ };
+
+ for (const [sitePermissionName, actualPermAction] of Object.entries(
+ sitePermissions
+ )) {
+ is(
+ actualPermAction,
+ expectedPermAction,
+ `The extension "${sitePermissionName}" SitePermission ${assertMessage} as expected`
+ );
+ }
+}
diff --git a/toolkit/components/extensions/test/mochitest/head_webrequest.js b/toolkit/components/extensions/test/mochitest/head_webrequest.js
new file mode 100644
index 0000000000..9e6b5cc910
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/head_webrequest.js
@@ -0,0 +1,481 @@
+"use strict";
+
+let commonEvents = {
+ onBeforeRequest: [{ urls: ["<all_urls>"] }, ["blocking"]],
+ onBeforeSendHeaders: [
+ { urls: ["<all_urls>"] },
+ ["blocking", "requestHeaders"],
+ ],
+ onSendHeaders: [{ urls: ["<all_urls>"] }, ["requestHeaders"]],
+ onBeforeRedirect: [{ urls: ["<all_urls>"] }],
+ onHeadersReceived: [
+ { urls: ["<all_urls>"] },
+ ["blocking", "responseHeaders"],
+ ],
+ // Auth tests will need to set their own events object
+ // "onAuthRequired": [{urls: ["<all_urls>"]}, ["blocking", "responseHeaders"]],
+ onResponseStarted: [{ urls: ["<all_urls>"] }],
+ onCompleted: [{ urls: ["<all_urls>"] }, ["responseHeaders"]],
+ onErrorOccurred: [{ urls: ["<all_urls>"] }],
+};
+
+function background(events) {
+ const IP_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
+
+ let expect;
+ let ignore;
+ let defaultOrigin;
+ let watchAuth = Object.keys(events).includes("onAuthRequired");
+ let expectedIp = null;
+
+ browser.test.onMessage.addListener((msg, expected) => {
+ if (msg !== "set-expected") {
+ return;
+ }
+ expect = expected.expect;
+ defaultOrigin = expected.origin;
+ ignore = expected.ignore;
+ let promises = [];
+ // Initialize some stuff we'll need in the tests.
+ for (let entry of Object.values(expect)) {
+ // a place for the test infrastructure to store some state.
+ entry.test = {};
+ // Each entry in expected gets a Promise that will be resolved in the
+ // last event for that entry. This will either be onCompleted, or the
+ // last entry if an events list was provided.
+ promises.push(
+ new Promise(resolve => {
+ entry.test.resolve = resolve;
+ })
+ );
+ // If events was left undefined, we're expecting all normal events we're
+ // listening for, exclude onBeforeRedirect and onErrorOccurred
+ if (entry.events === undefined) {
+ entry.events = Object.keys(events).filter(
+ name => name != "onErrorOccurred" && name != "onBeforeRedirect"
+ );
+ }
+ if (entry.optional_events === undefined) {
+ entry.optional_events = [];
+ }
+ }
+ // When every expected entry has finished our test is done.
+ Promise.all(promises).then(() => {
+ browser.test.sendMessage("done");
+ });
+ browser.test.sendMessage("continue");
+ });
+
+ // Retrieve the per-file/test expected values.
+ function getExpected(details) {
+ let url = new URL(details.url);
+ let filename = url.pathname.split("/").pop();
+ if (ignore && ignore.includes(filename)) {
+ return;
+ }
+ let expected = expect[filename];
+ if (!expected) {
+ browser.test.fail(`unexpected request ${filename}`);
+ return;
+ }
+ // Save filename for redirect verification.
+ expected.test.filename = filename;
+ return expected;
+ }
+
+ // Process any test header modifications that can happen in request or response phases.
+ // If a test includes headers, it needs a complete header object, no undefined
+ // objects even if empty:
+ // request: {
+ // add: {"HeaderName": "value",},
+ // modify: {"HeaderName": "value",},
+ // remove: ["HeaderName",],
+ // },
+ // response: {
+ // add: {"HeaderName": "value",},
+ // modify: {"HeaderName": "value",},
+ // remove: ["HeaderName",],
+ // },
+ function processHeaders(phase, expected, details) {
+ // This should only happen once per phase [request|response].
+ browser.test.assertFalse(
+ !!expected.test[phase],
+ `First processing of headers for ${phase}`
+ );
+ expected.test[phase] = true;
+
+ let headers = details[`${phase}Headers`];
+ browser.test.assertTrue(
+ Array.isArray(headers),
+ `${phase}Headers array present`
+ );
+
+ let { add, modify, remove } = expected.headers[phase];
+
+ for (let name in add) {
+ browser.test.assertTrue(
+ !headers.find(h => h.name === name),
+ `header ${name} to be added not present yet in ${phase}Headers`
+ );
+ let header = { name: name };
+ if (name.endsWith("-binary")) {
+ header.binaryValue = Array.from(add[name], c => c.charCodeAt(0));
+ } else {
+ header.value = add[name];
+ }
+ headers.push(header);
+ }
+
+ let modifiedAny = false;
+ for (let header of headers) {
+ if (header.name.toLowerCase() in modify) {
+ header.value = modify[header.name.toLowerCase()];
+ modifiedAny = true;
+ }
+ }
+ browser.test.assertTrue(
+ modifiedAny,
+ `at least one ${phase}Headers element to modify`
+ );
+
+ let deletedAny = false;
+ for (let j = headers.length; j-- > 0; ) {
+ if (remove.includes(headers[j].name.toLowerCase())) {
+ headers.splice(j, 1);
+ deletedAny = true;
+ }
+ }
+ browser.test.assertTrue(
+ deletedAny,
+ `at least one ${phase}Headers element to delete`
+ );
+
+ return headers;
+ }
+
+ // phase is request or response.
+ function checkHeaders(phase, expected, details) {
+ if (!/^https?:/.test(details.url)) {
+ return;
+ }
+
+ let headers = details[`${phase}Headers`];
+ browser.test.assertTrue(
+ Array.isArray(headers),
+ `valid ${phase}Headers array`
+ );
+
+ let { add, modify, remove } = expected.headers[phase];
+ for (let name in add) {
+ let value = headers.find(
+ h => h.name.toLowerCase() === name.toLowerCase()
+ ).value;
+ browser.test.assertEq(
+ value,
+ add[name],
+ `header ${name} correctly injected in ${phase}Headers`
+ );
+ }
+
+ for (let name in modify) {
+ let value = headers.find(
+ h => h.name.toLowerCase() === name.toLowerCase()
+ ).value;
+ browser.test.assertEq(
+ value,
+ modify[name],
+ `header ${name} matches modified value`
+ );
+ }
+
+ for (let name of remove) {
+ let found = headers.find(
+ h => h.name.toLowerCase() === name.toLowerCase()
+ );
+ browser.test.assertFalse(
+ !!found,
+ `deleted header ${name} still found in ${phase}Headers`
+ );
+ }
+ }
+
+ let listeners = {
+ onBeforeRequest(expected, details, result) {
+ // Save some values to test request consistency in later events.
+ browser.test.assertTrue(
+ details.tabId !== undefined,
+ `tabId ${details.tabId}`
+ );
+ browser.test.assertTrue(
+ details.requestId !== undefined,
+ `requestId ${details.requestId}`
+ );
+ // Validate requestId if it's already set, this happens with redirects.
+ if (expected.test.requestId !== undefined) {
+ browser.test.assertEq(
+ "string",
+ typeof expected.test.requestId,
+ `requestid ${expected.test.requestId} is string`
+ );
+ browser.test.assertEq(
+ "string",
+ typeof details.requestId,
+ `requestid ${details.requestId} is string`
+ );
+ browser.test.assertEq(
+ "number",
+ typeof parseInt(details.requestId, 10),
+ "parsed requestid is number"
+ );
+ browser.test.assertEq(
+ expected.test.requestId,
+ details.requestId,
+ "redirects will keep the same requestId"
+ );
+ } else {
+ // Save any values we want to validate in later events.
+ expected.test.requestId = details.requestId;
+ expected.test.tabId = details.tabId;
+ }
+ // Tests we don't need to do every event.
+ browser.test.assertTrue(
+ details.type.toUpperCase() in browser.webRequest.ResourceType,
+ `valid resource type ${details.type}`
+ );
+ if (details.type == "main_frame") {
+ browser.test.assertEq(
+ 0,
+ details.frameId,
+ "frameId is zero when type is main_frame, see bug 1329299"
+ );
+ }
+ },
+ onBeforeSendHeaders(expected, details, result) {
+ if (expected.headers && expected.headers.request) {
+ result.requestHeaders = processHeaders("request", expected, details);
+ }
+ if (expected.redirect) {
+ browser.test.log(`${name} redirect request`);
+ result.redirectUrl = details.url.replace(
+ expected.test.filename,
+ expected.redirect
+ );
+ }
+ },
+ onBeforeRedirect() {},
+ onSendHeaders(expected, details, result) {
+ if (expected.headers && expected.headers.request) {
+ checkHeaders("request", expected, details);
+ }
+ },
+ onResponseStarted() {},
+ onHeadersReceived(expected, details, result) {
+ let expectedStatus = expected.status || 200;
+ // If authentication is being requested we don't fail on the status code.
+ if (watchAuth && [401, 407].includes(details.statusCode)) {
+ expectedStatus = details.statusCode;
+ }
+ browser.test.assertEq(
+ expectedStatus,
+ details.statusCode,
+ `expected HTTP status received for ${details.url} ${details.statusLine}`
+ );
+ if (expected.headers && expected.headers.response) {
+ result.responseHeaders = processHeaders("response", expected, details);
+ }
+ },
+ onAuthRequired(expected, details, result) {
+ result.authCredentials = expected.authInfo;
+ },
+ onCompleted(expected, details, result) {
+ // If we have already completed a GET request for this url,
+ // and it was found, we expect for the response to come fromCache.
+ // expected.cached may be undefined, force boolean.
+ if (typeof expected.cached === "boolean") {
+ let expectCached =
+ expected.cached &&
+ details.method === "GET" &&
+ details.statusCode != 404;
+ browser.test.assertEq(
+ expectCached,
+ details.fromCache,
+ "fromCache is correct"
+ );
+ }
+ // We can only tell IPs for non-cached HTTP requests.
+ if (!details.fromCache && /^https?:/.test(details.url)) {
+ browser.test.assertTrue(
+ IP_PATTERN.test(details.ip),
+ `IP for ${details.url} looks IP-ish: ${details.ip}`
+ );
+
+ // We can't easily predict the IP ahead of time, so just make
+ // sure they're all consistent.
+ expectedIp = expectedIp || details.ip;
+ browser.test.assertEq(
+ expectedIp,
+ details.ip,
+ `correct ip for ${details.url}`
+ );
+ }
+ if (expected.headers && expected.headers.response) {
+ checkHeaders("response", expected, details);
+ }
+ },
+ onErrorOccurred(expected, details, result) {
+ if (expected.error) {
+ if (Array.isArray(expected.error)) {
+ browser.test.assertTrue(
+ expected.error.includes(details.error),
+ "expected error message received in onErrorOccurred"
+ );
+ } else {
+ browser.test.assertEq(
+ expected.error,
+ details.error,
+ "expected error message received in onErrorOccurred"
+ );
+ }
+ }
+ },
+ };
+
+ function getListener(name) {
+ return details => {
+ let result = {};
+ browser.test.log(`${name} ${details.requestId} ${details.url}`);
+ let expected = getExpected(details);
+ if (!expected) {
+ return result;
+ }
+ let expectedEvent = expected.events[0] == name;
+ if (expectedEvent) {
+ expected.events.shift();
+ } else {
+ // e10s vs. non-e10s errors can end with either onCompleted or onErrorOccurred
+ expectedEvent = expected.optional_events.includes(name);
+ }
+ browser.test.assertTrue(expectedEvent, `received ${name}`);
+ browser.test.assertEq(
+ expected.type,
+ details.type,
+ "resource type is correct"
+ );
+ browser.test.assertEq(
+ expected.origin || defaultOrigin,
+ details.originUrl,
+ "origin is correct"
+ );
+
+ if (name != "onBeforeRequest") {
+ // On events after onBeforeRequest, check the previous values.
+ browser.test.assertEq(
+ expected.test.requestId,
+ details.requestId,
+ "correct requestId"
+ );
+ browser.test.assertEq(
+ expected.test.tabId,
+ details.tabId,
+ "correct tabId"
+ );
+ }
+ try {
+ listeners[name](expected, details, result);
+ } catch (e) {
+ browser.test.fail(`unexpected webrequest failure ${name} ${e}`);
+ }
+
+ if (expected.cancel && expected.cancel == name) {
+ browser.test.log(`${name} cancel request`);
+ browser.test.sendMessage("cancelled");
+ result.cancel = true;
+ }
+ // If we've used up all the events for this test, resolve the promise.
+ // If something wrong happens and more events come through, there will be
+ // failures.
+ if (expected.events.length <= 0) {
+ expected.test.resolve();
+ }
+ return result;
+ };
+ }
+
+ for (let [name, args] of Object.entries(events)) {
+ browser.test.log(`adding listener for ${name}`);
+ try {
+ browser.webRequest[name].addListener(getListener(name), ...args);
+ } catch (e) {
+ browser.test.assertTrue(
+ /\brequestBody\b/.test(e.message),
+ "Request body is unsupported"
+ );
+
+ // RequestBody is disabled in release builds.
+ if (!/\brequestBody\b/.test(e.message)) {
+ throw e;
+ }
+
+ args.splice(args.indexOf("requestBody"), 1);
+ browser.webRequest[name].addListener(getListener(name), ...args);
+ }
+ }
+}
+
+/* exported makeExtension */
+
+function makeExtension(events = commonEvents) {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background: `(${background})(${JSON.stringify(events)})`,
+ });
+}
+
+/* exported addStylesheet */
+
+function addStylesheet(file) {
+ let link = document.createElement("link");
+ link.setAttribute("rel", "stylesheet");
+ link.setAttribute("href", file);
+ document.body.appendChild(link);
+}
+
+/* exported addLink */
+
+function addLink(file) {
+ let a = document.createElement("a");
+ a.setAttribute("href", file);
+ a.setAttribute("target", "_blank");
+ a.setAttribute("rel", "opener");
+ document.body.appendChild(a);
+ return a;
+}
+
+/* exported addImage */
+
+function addImage(file) {
+ let img = document.createElement("img");
+ img.setAttribute("src", file);
+ document.body.appendChild(img);
+}
+
+/* exported addScript */
+
+function addScript(file) {
+ let script = document.createElement("script");
+ script.setAttribute("type", "text/javascript");
+ script.setAttribute("src", file);
+ document.getElementsByTagName("head").item(0).appendChild(script);
+}
+
+/* exported addFrame */
+
+function addFrame(file) {
+ let frame = document.createElement("iframe");
+ frame.setAttribute("width", "200");
+ frame.setAttribute("height", "200");
+ frame.setAttribute("src", file);
+ document.body.appendChild(frame);
+}
diff --git a/toolkit/components/extensions/test/mochitest/hsts.sjs b/toolkit/components/extensions/test/mochitest/hsts.sjs
new file mode 100644
index 0000000000..52b9dd340b
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/hsts.sjs
@@ -0,0 +1,10 @@
+"use strict";
+
+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/toolkit/components/extensions/test/mochitest/mochitest-common.ini b/toolkit/components/extensions/test/mochitest/mochitest-common.ini
new file mode 100644
index 0000000000..83d4bdc41b
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/mochitest-common.ini
@@ -0,0 +1,350 @@
+[DEFAULT]
+tags = condprof
+support-files =
+ chrome_cleanup_script.js
+ file_WebNavigation_page1.html
+ file_WebNavigation_page2.html
+ file_WebNavigation_page3.html
+ file_WebRequest_page3.html
+ file_contains_img.html
+ file_contains_iframe.html
+ file_green.html
+ file_green_blue.html
+ file_contentscript_activeTab.html
+ file_contentscript_activeTab2.html
+ file_contentscript_iframe.html
+ file_image_bad.png
+ file_image_good.png
+ file_image_great.png
+ file_image_redirect.png
+ file_indexedDB.html
+ file_mixed.html
+ file_remote_frame.html
+ file_sample.html
+ file_sample.txt
+ file_sample.txt^headers^
+ file_script_bad.js
+ file_script_good.js
+ file_script_redirect.js
+ file_script_xhr.js
+ file_serviceWorker.html
+ file_simple_iframe_worker.html
+ file_simple_sandboxed_frame.html
+ file_simple_sandboxed_subframe.html
+ file_simple_xhr.html
+ file_simple_xhr_frame.html
+ file_simple_xhr_frame2.html
+ file_simple_sharedworker.js
+ file_simple_webrequest_worker.html
+ file_simple_worker.js
+ file_slowed_document.sjs
+ file_streamfilter.txt
+ file_style_bad.css
+ file_style_good.css
+ file_style_redirect.css
+ file_third_party.html
+ file_to_drawWindow.html
+ file_webNavigation_clientRedirect.html
+ file_webNavigation_clientRedirect_httpHeaders.html
+ file_webNavigation_clientRedirect_httpHeaders.html^headers^
+ file_webNavigation_frameClientRedirect.html
+ file_webNavigation_frameRedirect.html
+ file_webNavigation_manualSubframe.html
+ file_webNavigation_manualSubframe_page1.html
+ file_webNavigation_manualSubframe_page2.html
+ file_with_about_blank.html
+ file_with_subframes_and_embed.html
+ file_with_xorigin_frame.html
+ head.js
+ head_cookies.js
+ head_notifications.js
+ head_unlimitedStorage.js
+ head_webrequest.js
+ hsts.sjs
+ mochitest_console.js
+ oauth.html
+ redirect_auto.sjs
+ redirection.sjs
+ return_headers.sjs
+ serviceWorker.js
+ slow_response.sjs
+ webrequest_worker.js
+ !/dom/tests/mochitest/geolocation/network_geolocation.sjs
+ !/toolkit/components/passwordmgr/test/authenticate.sjs
+ file_redirect_data_uri.html
+ file_redirect_cors_bypass.html
+ file_tabs_permission_page1.html
+ file_tabs_permission_page2.html
+prefs =
+ security.mixed_content.upgrade_display_content=false
+ browser.chrome.guess_favicon=true
+
+[test_check_startupcache.html]
+[test_ext_action.html]
+[test_ext_activityLog.html]
+skip-if =
+ os == 'android'
+ tsan # Times out on TSan, bug 1612707
+ xorigin # Inconsistent pass/fail in opt and debug
+ http3
+[test_ext_async_clipboard.html]
+skip-if = toolkit == 'android' || tsan # near-permafail after landing bug 1270059: Bug 1523131. tsan: bug 1612707
+[test_ext_background_canvas.html]
+[test_ext_background_page.html]
+skip-if = (toolkit == 'android') # android doesn't have devtools
+[test_ext_background_page_dpi.html]
+[test_ext_browserAction_openPopup.html]
+skip-if =
+ http3
+[test_ext_browserAction_openPopup_incognito_window.html]
+skip-if = os == "android" # cannot open private windows - bug 1372178
+[test_ext_browserAction_openPopup_windowId.html]
+skip-if = os == "android" # only the current window is supported - bug 1795956
+[test_ext_browserAction_openPopup_without_pref.html]
+[test_ext_browsingData_indexedDB.html]
+skip-if =
+ http3
+[test_ext_browsingData_localStorage.html]
+skip-if =
+ http3
+[test_ext_browsingData_pluginData.html]
+[test_ext_browsingData_serviceWorkers.html]
+skip-if =
+ condprof # "Wait for 2 service workers to be registered - timed out after 50 tries."
+ http3
+[test_ext_browsingData_settings.html]
+[test_ext_canvas_resistFingerprinting.html]
+skip-if =
+ http3
+[test_ext_clipboard.html]
+skip-if =
+ os == 'android'
+ http3
+[test_ext_clipboard_image.html]
+skip-if = headless # Bug 1405872
+[test_ext_contentscript_about_blank.html]
+skip-if =
+ os == 'android' # bug 1369440
+ condprof #: "exactly 7 more scripts ran - got 11, expected 10"
+ http3
+[test_ext_contentscript_activeTab.html]
+skip-if =
+ http3
+[test_ext_contentscript_cache.html]
+skip-if = (os == 'linux' && debug) || (toolkit == 'android' && debug) # bug 1348241
+fail-if = xorigin # TypeError: can't access property "staticScripts", ext is undefined - Should not throw any errors
+[test_ext_contentscript_canvas.html]
+skip-if = (os == 'android') || (verify && debug && (os == 'linux')) # Bug 1617062
+[test_ext_contentscript_devtools_metadata.html]
+skip-if =
+ http3
+[test_ext_contentscript_fission_frame.html]
+skip-if =
+ http3
+[test_ext_contentscript_getFrameId.html]
+[test_ext_contentscript_incognito.html]
+skip-if =
+ os == 'android' # Android does not support multiple windows.
+ http3
+[test_ext_contentscript_permission.html]
+skip-if = tsan # Times out on TSan, bug 1612707
+[test_ext_cookies.html]
+skip-if =
+ os == 'android' || tsan # Times out on TSan intermittently, bug 1615184; not supported on Android yet
+ condprof #: "one tabId returned for store - Expected: 1, Actual: 3"
+ http3
+[test_ext_cookies_containers.html]
+[test_ext_cookies_expiry.html]
+[test_ext_cookies_first_party.html]
+[test_ext_cookies_incognito.html]
+skip-if = os == 'android' # Bug 1513544 Android does not support multiple windows.
+[test_ext_cookies_permissions_bad.html]
+[test_ext_cookies_permissions_good.html]
+[test_ext_dnr_other_extensions.html]
+[test_ext_dnr_tabIds.html]
+[test_ext_dnr_upgradeScheme.html]
+skip-if =
+ http3
+[test_ext_downloads_download.html]
+[test_ext_embeddedimg_iframe_frameAncestors.html]
+skip-if =
+ http3
+[test_ext_exclude_include_globs.html]
+skip-if =
+ http3
+[test_ext_extension_iframe_messaging.html]
+skip-if =
+ http3
+[test_ext_external_messaging.html]
+[test_ext_generate.html]
+[test_ext_geolocation.html]
+skip-if = os == 'android' # Android support Bug 1336194
+[test_ext_identity.html]
+skip-if =
+ win11_2009 && !debug && socketprocess_networking # Bug 1777016
+ os == 'android' || tsan # unsupported. tsan: bug 1612707
+[test_ext_idle.html]
+skip-if = tsan # Times out on TSan, bug 1612707
+[test_ext_inIncognitoContext_window.html]
+skip-if = os == 'android' # Android does not support multiple windows.
+[test_ext_listener_proxies.html]
+[test_ext_new_tab_processType.html]
+skip-if =
+ verify && debug && (os == 'linux' || os == 'mac')
+ condprof #: Page URL should match - got "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_serviceWorker.html", expected "https://example.com/"
+ http3
+[test_ext_notifications.html]
+skip-if = os == 'android' # Not supported on Android yet
+[test_ext_optional_permissions.html]
+[test_ext_protocolHandlers.html]
+skip-if = (toolkit == 'android') # bug 1342577
+[test_ext_redirect_jar.html]
+skip-if = os == 'win' && (debug || asan) # Bug 1563440
+[test_ext_request_urlClassification.html]
+skip-if =
+ os == 'android' # Bug 1615427
+ http3
+[test_ext_runtime_connect.html]
+skip-if =
+ http3
+[test_ext_runtime_connect_iframe.html]
+[test_ext_runtime_connect_twoway.html]
+skip-if =
+ http3
+[test_ext_runtime_connect2.html]
+skip-if =
+ http3
+[test_ext_runtime_disconnect.html]
+skip-if =
+ http3
+[test_ext_script_filenames.html]
+[test_ext_scripting_contentScripts.html]
+skip-if =
+ http3
+[test_ext_scripting_executeScript.html]
+skip-if =
+ http3
+[test_ext_scripting_executeScript_activeTab.html]
+skip-if =
+ http3
+[test_ext_scripting_executeScript_injectImmediately.html]
+skip-if =
+ http3
+[test_ext_scripting_insertCSS.html]
+skip-if =
+ http3
+[test_ext_scripting_permissions.html]
+skip-if =
+ http3
+[test_ext_scripting_removeCSS.html]
+skip-if =
+ http3
+[test_ext_sendmessage_doublereply.html]
+skip-if =
+ http3
+[test_ext_sendmessage_frameId.html]
+[test_ext_sendmessage_no_receiver.html]
+skip-if =
+ http3
+[test_ext_sendmessage_reply.html]
+skip-if =
+ http3
+[test_ext_sendmessage_reply2.html]
+skip-if =
+ os == 'android'
+ http3
+[test_ext_storage_manager_capabilities.html]
+skip-if =
+ xorigin # JavaScript Error: "SecurityError: Permission denied to access property "wrappedJSObject" on cross-origin object" {file: "https://example.com/tests/SimpleTest/TestRunner.js" line: 157}
+ http3
+scheme=https
+[test_ext_storage_smoke_test.html]
+[test_ext_streamfilter_multiple.html]
+skip-if =
+ !debug # Bug 1628642
+ os == 'linux' # Bug 1628642
+[test_ext_streamfilter_processswitch.html]
+skip-if =
+ http3
+[test_ext_subframes_privileges.html]
+skip-if =
+ os == 'android' || verify # bug 1489771
+ http3
+[test_ext_tabs_captureTab.html]
+skip-if =
+ http3
+[test_ext_tabs_executeScript_good.html]
+skip-if =
+ http3
+[test_ext_tabs_create_cookieStoreId.html]
+[test_ext_tabs_query_popup.html]
+[test_ext_tabs_permissions.html]
+skip-if =
+ http3
+[test_ext_tabs_sendMessage.html]
+skip-if =
+ http3
+[test_ext_test.html]
+skip-if =
+ http3
+[test_ext_unlimitedStorage.html]
+skip-if = os == 'android'
+[test_ext_web_accessible_resources.html]
+skip-if = (os == 'android' && debug) || (os == "linux" && bits == 64) # bug 1397615, bug 1618231
+[test_ext_web_accessible_incognito.html]
+skip-if = (os == 'android') # bug 1397615, bug 1513544
+[test_ext_webnavigation.html]
+skip-if =
+ (os == 'android' && debug) # bug 1397615
+ http3
+[test_ext_webnavigation_filters.html]
+skip-if =
+ (os == 'android' && debug) || (verify && (os == 'linux' || os == 'mac')) # bug 1397615
+ http3
+[test_ext_webnavigation_incognito.html]
+skip-if =
+ os == 'android' # bug 1513544
+ http3
+[test_ext_webrequest_and_proxy_filter.html]
+skip-if =
+ http3
+[test_ext_webrequest_auth.html]
+skip-if =
+ os == 'android'
+ http3
+[test_ext_webrequest_background_events.html]
+[test_ext_webrequest_basic.html]
+skip-if =
+ os == 'android' && debug # bug 1397615
+ tsan # bug 1612707
+ xorigin # JavaScript Error: "SecurityError: Permission denied to access property "wrappedJSObject" on cross-origin object" {file: "http://mochi.false-test:8888/tests/SimpleTest/TestRunner.js" line: 157}]
+ os == "linux" && bits == 64 && !debug && asan # Bug 1633189
+ http3
+[test_ext_webrequest_errors.html]
+skip-if =
+ tsan
+ http3
+[test_ext_webrequest_filter.html]
+skip-if =
+ os == 'android' && debug || tsan # bug 1452348. tsan: bug 1612707
+ os == 'linux' && bits == 64 && !debug && xorigin # Bug 1756023
+[test_ext_webrequest_frameId.html]
+[test_ext_webrequest_getSecurityInfo.html]
+skip-if =
+ http3
+[test_ext_webrequest_hsts.html]
+https_first_disabled = true
+skip-if =
+ http3
+[test_ext_webrequest_upgrade.html]
+https_first_disabled = true
+[test_ext_webrequest_upload.html]
+skip-if = os == 'android' # Currently fails in emulator tests
+[test_ext_webrequest_redirect_bypass_cors.html]
+[test_ext_webrequest_redirect_data_uri.html]
+[test_ext_webrequest_worker.html]
+[test_ext_window_postMessage.html]
+skip-if =
+ http3
+# test_startup_canary.html is at the bottom to minimize the time spent waiting in the test.
+[test_startup_canary.html]
diff --git a/toolkit/components/extensions/test/mochitest/mochitest-remote.ini b/toolkit/components/extensions/test/mochitest/mochitest-remote.ini
new file mode 100644
index 0000000000..0a66b11755
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/mochitest-remote.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+tags = webextensions remote-webextensions
+skip-if = os == 'android' # Bug 1620091: disable on android until extension process is done
+prefs =
+ extensions.webextensions.remote=true
+ # We don't want to reset this at the end of the test, so that we don't have
+ # to spawn a new extension child process for each test unit.
+ dom.ipc.keepProcessesAlive.extension=1
+
+[test_verify_remote_mode.html]
+[include:mochitest-common.ini]
diff --git a/toolkit/components/extensions/test/mochitest/mochitest-serviceworker.ini b/toolkit/components/extensions/test/mochitest/mochitest-serviceworker.ini
new file mode 100644
index 0000000000..4468349d84
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/mochitest-serviceworker.ini
@@ -0,0 +1,28 @@
+[DEFAULT]
+tags = webextensions sw-webextensions condprof
+skip-if =
+ !e10s # Thunderbird does still run in non e10s mode (and so also with in-process-webextensions mode)
+ (os == 'android') # Bug 1620091: disable on android until extension process is done
+ http3
+
+prefs =
+ extensions.webextensions.remote=true
+ # We don't want to reset this at the end of the test, so that we don't have
+ # to spawn a new extension child process for each test unit.
+ dom.ipc.keepProcessesAlive.extension=1
+ extensions.backgroundServiceWorker.enabled=true
+ extensions.backgroundServiceWorker.forceInTestExtension=true
+
+dupe-manifest = true
+
+# `test_verify_sw_mode.html` should be the first one, even if it breaks the
+# alphabetical order.
+[test_verify_sw_mode.html]
+[test_ext_scripting_contentScripts.html]
+[test_ext_scripting_executeScript.html]
+skip-if = true # Bug 1748315 - Add WebIDL bindings for `scripting.executeScript()`
+[test_ext_scripting_insertCSS.html]
+skip-if = true # Bug 1748318 - Add WebIDL bindings for `tabs`
+[test_ext_scripting_removeCSS.html]
+skip-if = true # Bug 1748318 - Add WebIDL bindings for `tabs`
+[test_ext_test.html]
diff --git a/toolkit/components/extensions/test/mochitest/mochitest.ini b/toolkit/components/extensions/test/mochitest/mochitest.ini
new file mode 100644
index 0000000000..f2f6117726
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/mochitest.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+tags = webextensions in-process-webextensions
+prefs =
+ extensions.webextensions.remote=false
+ javascript.options.asyncstack_capture_debuggee_only=false
+dupe-manifest = true
+
+[test_verify_non_remote_mode.html]
+[test_ext_storage_cleanup.html]
+# Bug 1426514 storage_cleanup: clearing localStorage fails with oop
+
+[include:mochitest-common.ini]
+skip-if = os == 'win' # Windows WebExtensions always run OOP
diff --git a/toolkit/components/extensions/test/mochitest/mochitest_console.js b/toolkit/components/extensions/test/mochitest/mochitest_console.js
new file mode 100644
index 0000000000..582e12b48f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/mochitest_console.js
@@ -0,0 +1,54 @@
+/* eslint-env mozilla/chrome-script */
+
+"use strict";
+
+const { addMessageListener, sendAsyncMessage } = this;
+
+// Much of the console monitoring code is copied from TestUtils but simplified
+// to our needs.
+function monitorConsole(msgs) {
+ function msgMatches(msg, pat) {
+ for (let k in pat) {
+ if (!(k in msg)) {
+ return false;
+ }
+ if (pat[k] instanceof RegExp && typeof msg[k] === "string") {
+ if (!pat[k].test(msg[k])) {
+ return false;
+ }
+ } else if (msg[k] !== pat[k]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ let counter = 0;
+ function listener(msg) {
+ if (msgMatches(msg, msgs[counter])) {
+ counter++;
+ }
+ }
+ addMessageListener("waitForConsole", () => {
+ sendAsyncMessage("consoleDone", {
+ ok: counter >= msgs.length,
+ message: `monitorConsole | messages left expected at least ${msgs.length} got ${counter}`,
+ });
+ Services.console.unregisterListener(listener);
+ });
+
+ Services.console.registerListener(listener);
+}
+
+addMessageListener("consoleStart", messages => {
+ for (let msg of messages) {
+ // Message might be a RegExp object from a different compartment, but
+ // instanceof RegExp will fail. If we have an object, lets just make
+ // sure.
+ let message = msg.message;
+ if (typeof message == "object" && !(message instanceof RegExp)) {
+ msg.message = new RegExp(message);
+ }
+ }
+ monitorConsole(messages);
+});
diff --git a/toolkit/components/extensions/test/mochitest/oauth.html b/toolkit/components/extensions/test/mochitest/oauth.html
new file mode 100644
index 0000000000..8b9b1d65ec
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/oauth.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <script>
+ "use strict";
+
+ onload = () => {
+ let url = new URL(location);
+ if (url.searchParams.get("post")) {
+ let server_redirect = `${url.searchParams.get("server_uri")}?redirect_uri=${encodeURIComponent(url.searchParams.get("redirect_uri"))}`;
+ let form = document.forms.testform;
+ form.setAttribute("action", server_redirect);
+ form.submit();
+ } else {
+ let end = new URL(url.searchParams.get("redirect_uri"));
+ end.searchParams.set("access_token", "here ya go");
+ location.href = end.href;
+ }
+ };
+ </script>
+</head>
+<body>
+ <form name="testform" action="" method="POST">
+ </form>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/redirect_auto.sjs b/toolkit/components/extensions/test/mochitest/redirect_auto.sjs
new file mode 100644
index 0000000000..bf7af2556b
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/redirect_auto.sjs
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+Cu.importGlobalProperties(["URLSearchParams", "URL"]);
+
+function handleRequest(request, response) {
+ let params = new URLSearchParams(request.queryString);
+ if (params.has("no_redirect")) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+ } else {
+ if (request.method == "POST") {
+ response.setStatusLine(request.httpVersion, 303, "Redirected");
+ } else {
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ }
+ let url = new URL(
+ params.get("redirect_uri") || params.get("default_redirect")
+ );
+ url.searchParams.set("access_token", "here ya go");
+ response.setHeader("Location", url.href);
+ }
+}
diff --git a/toolkit/components/extensions/test/mochitest/redirection.sjs b/toolkit/components/extensions/test/mochitest/redirection.sjs
new file mode 100644
index 0000000000..873a3d41ba
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/redirection.sjs
@@ -0,0 +1,6 @@
+"use strict";
+
+function handleRequest(request, response) {
+ response.setStatusLine(request.httpVersion, 302);
+ response.setHeader("Location", "./dummy_page.html");
+}
diff --git a/toolkit/components/extensions/test/mochitest/return_headers.sjs b/toolkit/components/extensions/test/mochitest/return_headers.sjs
new file mode 100644
index 0000000000..46beab8185
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/return_headers.sjs
@@ -0,0 +1,19 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* exported handleRequest */
+
+function handleRequest(request, response) {
+ response.setHeader("Content-Type", "text/plain", false);
+
+ let headers = {};
+ // Why on earth is this a nsISimpleEnumerator...
+ let enumerator = request.headers;
+ while (enumerator.hasMoreElements()) {
+ let header = enumerator.getNext().data;
+ headers[header.toLowerCase()] = request.getHeader(header);
+ }
+
+ response.write(JSON.stringify(headers));
+}
diff --git a/toolkit/components/extensions/test/mochitest/serviceWorker.js b/toolkit/components/extensions/test/mochitest/serviceWorker.js
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/serviceWorker.js
diff --git a/toolkit/components/extensions/test/mochitest/slow_response.sjs b/toolkit/components/extensions/test/mochitest/slow_response.sjs
new file mode 100644
index 0000000000..d39c4c0bf0
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/slow_response.sjs
@@ -0,0 +1,60 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */
+"use strict";
+
+/* eslint-disable no-unused-vars */
+
+let { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const DELAY = AppConstants.DEBUG ? 4000 : 800;
+
+let nsTimer = Components.Constructor(
+ "@mozilla.org/timer;1",
+ "nsITimer",
+ "initWithCallback"
+);
+
+let timer;
+function delay() {
+ return new Promise(resolve => {
+ timer = nsTimer(resolve, DELAY, Ci.nsITimer.TYPE_ONE_SHOT);
+ });
+}
+
+const PARTS = [
+ `<!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title></title>
+ </head>
+ <body>`,
+ "Lorem ipsum dolor sit amet, <br>",
+ "consectetur adipiscing elit, <br>",
+ "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. <br>",
+ "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. <br>",
+ "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <br>",
+ "Excepteur sint occaecat cupidatat non proident, <br>",
+ "sunt in culpa qui officia deserunt mollit anim id est laborum.<br>",
+ `
+ </body>
+ </html>`,
+];
+
+async function handleRequest(request, response) {
+ response.processAsync();
+
+ response.setHeader("Content-Type", "text/html", false);
+ response.setHeader("Cache-Control", "no-cache", false);
+
+ await delay();
+
+ for (let part of PARTS) {
+ response.write(`${part}\n`);
+ await delay();
+ }
+
+ response.finish();
+}
diff --git a/toolkit/components/extensions/test/mochitest/test_check_startupcache.html b/toolkit/components/extensions/test/mochitest/test_check_startupcache.html
new file mode 100644
index 0000000000..01d361ca5e
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_check_startupcache.html
@@ -0,0 +1,61 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Check StartupCache</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function check_ExtensionParent_StartupCache_is_non_empty() {
+ // This test aims to verify that the StartupCache of extensions is populated.
+ // Ideally, we would load an extension, restart the browser and confirm the
+ // existence of the StartupCache. That is not possible in a mochitest.
+ // So we will just read the contents of the StartupCache and verify that it
+ // populated and assume that it carries over to the next startup.
+ // The latter is checked in test_startup_canary.html
+
+ const { WebExtensionPolicy } = SpecialPowers.Cu.getGlobalForObject(SpecialPowers.Services);
+ // The Mochikit extension is part of the mochitests framework, so the fact
+ // that this test runs implies that the extension should have been started.
+ ok(
+ WebExtensionPolicy.getByID("mochikit@mozilla.org"),
+ "This test expects the Mochikit extension to be running"
+ );
+
+ let chromeScript = loadChromeScript(() => {
+ const {
+ ExtensionParent,
+ } = ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm");
+ const { StartupCache } = ExtensionParent;
+ this.sendAsyncMessage("StartupCache_data", StartupCache._data);
+ });
+
+ let map = await chromeScript.promiseOneMessage("StartupCache_data");
+ chromeScript.destroy();
+
+ // "manifests" is populated by Extension's parseManifest in Extension.jsm.
+ const keys = ["manifests", "mochikit@mozilla.org", "2.0", "en-US"];
+ for (let key of keys) {
+ map = map.get(key);
+ ok(map, `StartupCache data map contains ${key}`);
+ }
+
+ // At this point `map` is expected to be the return value of
+ // ExtensionData's parseManifest.
+
+ is(
+ map?.manifest?.applications?.gecko?.id,
+ "mochikit@mozilla.org",
+ "StartupCache.manifests contains a parsed manifest"
+ );
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html
new file mode 100644
index 0000000000..42950c50ec
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html
@@ -0,0 +1,104 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test content script matching a data: URI</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script>
+"use strict";
+
+add_task(async function test_contentscript_data_uri() {
+ const target = ExtensionTestUtils.loadExtension({
+ files: {
+ "page.html": `<!DOCTYPE html>
+ <meta charset="utf-8">
+ <iframe id="inherited" src="data:text/html;charset=utf-8,inherited"></iframe>
+ `,
+ },
+ background() {
+ browser.test.sendMessage("page", browser.runtime.getURL("page.html"));
+ },
+ });
+
+ const scripts = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webNavigation"],
+ content_scripts: [{
+ all_frames: true,
+ matches: ["<all_urls>"],
+ run_at: "document_start",
+ css: ["all_urls.css"],
+ js: ["all_urls.js"],
+ }],
+ },
+ files: {
+ "all_urls.css": `
+ body { background: yellow; }
+ `,
+ "all_urls.js": function() {
+ document.body.style.color = "red";
+ browser.test.assertTrue(location.protocol !== "data:",
+ `Matched document not a data URI: ${location.href}`);
+ },
+ },
+ background() {
+ browser.webNavigation.onCompleted.addListener(({url, frameId}) => {
+ browser.test.log(`Document loading complete: ${url}`);
+ if (frameId === 0) {
+ browser.test.sendMessage("tab-ready", url);
+ }
+ });
+ },
+ });
+
+ await target.startup();
+ await scripts.startup();
+
+ // Test extension page with a data: iframe.
+ const page = await target.awaitMessage("page");
+
+ // Hold on to the tab by the browser, as extension loads are COOP loads, and
+ // will break WindowProxy references.
+ let win = window.open();
+ const browserFrame = win.browsingContext.embedderElement;
+ win.location.href = page;
+
+ await scripts.awaitMessage("tab-ready");
+ win = browserFrame.contentWindow;
+ is(win.location.href, page, "Extension page loaded into a tab");
+ is(win.document.readyState, "complete", "Page finished loading");
+
+ const iframe = win.document.getElementById("inherited").contentWindow;
+ is(iframe.document.readyState, "complete", "iframe finished loading");
+
+ const style1 = iframe.getComputedStyle(iframe.document.body);
+ is(style1.color, "rgb(0, 0, 0)", "iframe text color is unmodified");
+ is(style1.backgroundColor, "rgba(0, 0, 0, 0)", "iframe background unmodified");
+
+ // Test extension tab navigated to a data: URI.
+ const data = "data:text/html;charset=utf-8,also-inherits";
+ win.location.href = data;
+
+ await scripts.awaitMessage("tab-ready");
+ win = browserFrame.contentWindow;
+ is(win.location.href, data, "Extension tab navigated to a data: URI");
+ is(win.document.readyState, "complete", "Tab finished loading");
+
+ const style2 = win.getComputedStyle(win.document.body);
+ is(style2.color, "rgb(0, 0, 0)", "Tab text color is unmodified");
+ is(style2.backgroundColor, "rgba(0, 0, 0, 0)", "Tab background unmodified");
+
+ win.close();
+ await target.unload();
+ await scripts.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html
new file mode 100644
index 0000000000..224e806288
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html
@@ -0,0 +1,68 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test for telemetry for content script injection</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script>
+"use strict";
+
+const HISTOGRAM = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS";
+
+add_task(async function test_contentscript_telemetry() {
+ // Turn on telemetry and reset it to the previous state once the test is completed.
+ const telemetryCanRecordBase = SpecialPowers.Services.telemetry.canRecordBase;
+ SpecialPowers.Services.telemetry.canRecordBase = true;
+ SimpleTest.registerCleanupFunction(() => {
+ SpecialPowers.Services.telemetry.canRecordBase = telemetryCanRecordBase;
+ });
+
+ function background() {
+ browser.test.onMessage.addListener(() => {
+ browser.tabs.executeScript({code: 'browser.test.sendMessage("content-script-run");'});
+ });
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: ["<all_urls>"],
+ },
+ background,
+ };
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://example.com",
+ true
+ );
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ let histogram = SpecialPowers.Services.telemetry.getHistogramById(HISTOGRAM);
+ histogram.clear();
+ is(histogram.snapshot().sum, 0,
+ `No data recorded for histogram: ${HISTOGRAM}.`);
+
+ await extension.startup();
+ is(histogram.snapshot().sum, 0,
+ `No data recorded for histogram after startup: ${HISTOGRAM}.`);
+
+ extension.sendMessage();
+ await extension.awaitMessage("content-script-run");
+
+ let histogramSum = histogram.snapshot().sum;
+ ok(histogramSum > 0,
+ `Data recorded for first extension for histogram: ${HISTOGRAM}.`);
+
+ await AppTestDelegate.removeTab(window, tab);
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html
new file mode 100644
index 0000000000..40403dea2b
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html
@@ -0,0 +1,80 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script unrecognized property on manifest</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const BASE = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest";
+
+add_task(async function test_contentscript() {
+ function background() {
+ browser.runtime.onMessage.addListener(async (msg) => {
+ if (msg == "loaded") {
+ // NOTE: we're removing the tab from here because doing a win.close()
+ // from the chrome test code is raising a "TypeError: can't access
+ // dead object" exception.
+ let tabs = await browser.tabs.query({active: true, currentWindow: true});
+ await browser.tabs.remove(tabs[0].id);
+
+ browser.test.notifyPass("content-script-loaded");
+ }
+ });
+ }
+
+ function contentScript() {
+ chrome.runtime.sendMessage("loaded");
+ }
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_idle",
+ "unrecognized_property": "with-a-random-value",
+ },
+ ],
+ },
+ background,
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ SimpleTest.waitForExplicitFinish();
+ let waitForConsole = new Promise(resolve => {
+ SimpleTest.monitorConsole(resolve, [{
+ message: /Reading manifest: Warning processing content_scripts.*.unrecognized_property: An unexpected property was found/,
+ }]);
+ });
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ window.open(`${BASE}/file_sample.html`);
+
+ await Promise.all([extension.awaitFinish("content-script-loaded")]);
+ info("test page loaded");
+
+ await extension.unload();
+
+ SimpleTest.endMonitorConsole();
+ await waitForConsole;
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html
new file mode 100644
index 0000000000..530937c1ac
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html
@@ -0,0 +1,114 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for permissions</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_downloads_open_permission() {
+ function backgroundScript() {
+ browser.test.assertEq(browser.downloads.open, undefined,
+ "`downloads.open` permission is required.");
+ browser.test.notifyPass("downloads tests");
+ }
+
+ let extensionData = {
+ background: backgroundScript,
+ manifest: {
+ permissions: ["downloads"],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitFinish("downloads tests");
+ await extension.unload();
+});
+
+add_task(async function test_downloads_open_requires_user_interaction() {
+ async function backgroundScript() {
+ await browser.test.assertRejects(
+ browser.downloads.open(10),
+ "downloads.open may only be called from a user input handler",
+ "The error is informative.");
+
+ browser.test.notifyPass("downloads tests");
+ }
+
+ let extensionData = {
+ background: backgroundScript,
+ manifest: {
+ permissions: ["downloads", "downloads.open"],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitFinish("downloads tests");
+ await extension.unload();
+});
+
+add_task(async function downloads_open_invalid_id() {
+ async function pageScript() {
+ window.addEventListener("keypress", async function handler() {
+ try {
+ await browser.downloads.open(10);
+ browser.test.sendMessage("download-open.result", {success: true});
+ } catch (e) {
+ browser.test.sendMessage("download-open.result", {
+ success: false,
+ error: e.message,
+ });
+ }
+ window.removeEventListener("keypress", handler);
+ });
+
+ browser.test.sendMessage("page-ready");
+ }
+
+ let extensionData = {
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+ files: {
+ "foo.txt": "It's the file called foo.txt.",
+ "page.html": `<html><head>
+ <script src="page.js"><\/script>
+ </head></html>`,
+ "page.js": pageScript,
+ },
+ manifest: {
+ permissions: ["downloads", "downloads.open"],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let url = await extension.awaitMessage("ready");
+ let win = window.open();
+ let browserFrame = win.browsingContext.embedderElement;
+ win.location.href = url;
+ await extension.awaitMessage("page-ready");
+
+ synthesizeKey("a", {}, browserFrame.contentWindow);
+ let result = await extension.awaitMessage("download-open.result");
+
+ is(result.success, false, "Opening download fails.");
+ is(result.error, "Invalid download id 10", "The error is informative.");
+
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html
new file mode 100644
index 0000000000..64cfcfd289
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html
@@ -0,0 +1,257 @@
+<!doctype html>
+<html>
+<head>
+ <title>Test downloads.download() saveAs option</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {FileUtils} = ChromeUtils.import("resource://gre/modules/FileUtils.jsm");
+
+const PROMPTLESS_DOWNLOAD_PREF = "browser.download.useDownloadDir";
+
+const DOWNLOAD_FILENAME = "file_download.nonext.txt";
+const DEFAULT_SUBDIR = "subdir";
+
+// We need to be able to distinguish files downloaded by the file picker from
+// files downloaded without it.
+let pickerDir;
+let pbPickerDir; // for incognito downloads
+let defaultDir;
+
+add_task(async function setup() {
+ // Reset DownloadLastDir preferences in case other tests set them.
+ SpecialPowers.Services.obs.notifyObservers(
+ null,
+ "browser:purge-session-history"
+ );
+
+ // Set up temporary directories.
+ let downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ pickerDir = downloadDir.clone();
+ pickerDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`Using file picker download directory ${pickerDir.path}`);
+ pbPickerDir = downloadDir.clone();
+ pbPickerDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`Using private browsing file picker download directory ${pbPickerDir.path}`);
+ defaultDir = downloadDir.clone();
+ defaultDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`Using default download directory ${defaultDir.path}`);
+ let subDir = defaultDir.clone();
+ subDir.append(DEFAULT_SUBDIR);
+ subDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ isnot(pickerDir.path, defaultDir.path,
+ "Should be able to distinguish between files saved with or without the file picker");
+ isnot(pickerDir.path, pbPickerDir.path,
+ "Should be able to distinguish between files saved in and out of private browsing mode");
+
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["browser.download.folderList", 2],
+ ["browser.download.dir", defaultDir.path],
+ ]});
+
+ SimpleTest.registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ pickerDir.remove(true);
+ pbPickerDir.remove(true);
+ defaultDir.remove(true); // This also removes DEFAULT_SUBDIR.
+ });
+});
+
+add_task(async function test_downloads_saveAs() {
+ const pickerFile = pickerDir.clone();
+ pickerFile.append(DOWNLOAD_FILENAME);
+
+ const pbPickerFile = pbPickerDir.clone();
+ pbPickerFile.append(DOWNLOAD_FILENAME);
+
+ const defaultFile = defaultDir.clone();
+ defaultFile.append(DOWNLOAD_FILENAME);
+
+ const {MockFilePicker} = SpecialPowers;
+ MockFilePicker.init(window);
+
+ function mockFilePickerCallback(expectedStartingDir, pickedFile) {
+ return fp => {
+ // Assert that the downloads API correctly sets the starting directory.
+ ok(fp.displayDirectory.equals(expectedStartingDir), "Got the expected FilePicker displayDirectory");
+
+ // Assert that the downloads API configures both default properties.
+ is(fp.defaultString, DOWNLOAD_FILENAME, "Got the expected FilePicker defaultString");
+ is(fp.defaultExtension, "txt", "Got the expected FilePicker defaultExtension");
+
+ MockFilePicker.setFiles([pickedFile]);
+ };
+ }
+
+ function background() {
+ const url = URL.createObjectURL(new Blob(["file content"]));
+ browser.test.onMessage.addListener(async (filename, saveAs, isPrivate) => {
+ try {
+ let options = {
+ url,
+ filename,
+ incognito: isPrivate,
+ };
+ // Only define the saveAs option if the argument was actually set
+ if (saveAs !== undefined) {
+ options.saveAs = saveAs;
+ }
+ let id = await browser.downloads.download(options);
+ browser.downloads.onChanged.addListener(delta => {
+ if (delta.id == id && delta.state.current === "complete") {
+ browser.test.sendMessage("done", {ok: true, id});
+ }
+ });
+ } catch ({message}) {
+ browser.test.sendMessage("done", {ok: false, message});
+ }
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ const manifest = {
+ background,
+ incognitoOverride: "spanning",
+ manifest: {permissions: ["downloads"]},
+ };
+ const extension = ExtensionTestUtils.loadExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ // options should have the following properties:
+ // saveAs (Boolean or undefined)
+ // isPrivate (Boolean)
+ // fileName (string)
+ // expectedStartingDir (nsIFile)
+ // destinationFile (nsIFile)
+ async function testExpectFilePicker(options) {
+ ok(!options.destinationFile.exists(), "the file should have been cleaned up properly previously");
+
+ MockFilePicker.showCallback = mockFilePickerCallback(
+ options.expectedStartingDir,
+ options.destinationFile
+ );
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+
+ extension.sendMessage(options.fileName, options.saveAs, options.isPrivate);
+ let result = await extension.awaitMessage("done");
+ ok(result.ok, `downloads.download() works with saveAs=${options.saveAs}`);
+
+ ok(options.destinationFile.exists(), "the file exists.");
+ is(options.destinationFile.fileSize, 12, "downloaded file is the correct size");
+ options.destinationFile.remove(false);
+ MockFilePicker.reset();
+
+ // Test the user canceling the save dialog.
+ MockFilePicker.returnValue = MockFilePicker.returnCancel;
+
+ extension.sendMessage(options.fileName, options.saveAs, options.isPrivate);
+ result = await extension.awaitMessage("done");
+
+ ok(!result.ok, "download rejected if the user cancels the dialog");
+ is(result.message, "Download canceled by the user", "with the correct message");
+ ok(!options.destinationFile.exists(), "file was not downloaded");
+ MockFilePicker.reset();
+ }
+
+ async function testNoFilePicker(saveAs) {
+ ok(!defaultFile.exists(), "the file should have been cleaned up properly previously");
+
+ extension.sendMessage(DOWNLOAD_FILENAME, saveAs, false);
+ let result = await extension.awaitMessage("done");
+ ok(result.ok, `downloads.download() works with saveAs=${saveAs}`);
+
+ ok(defaultFile.exists(), "the file exists.");
+ is(defaultFile.fileSize, 12, "downloaded file is the correct size");
+ defaultFile.remove(false);
+ }
+
+ info("Testing that saveAs=true uses the file picker as expected");
+ let expectedStartingDir = defaultDir;
+ let fpOptions = {
+ saveAs: true,
+ isPrivate: false,
+ fileName: DOWNLOAD_FILENAME,
+ expectedStartingDir: expectedStartingDir,
+ destinationFile: pickerFile,
+ };
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveas=true reuses last file picker directory");
+ fpOptions.expectedStartingDir = pickerDir;
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveAs=true in PB reuses last directory");
+ let nonPBStartingDir = fpOptions.expectedStartingDir;
+ fpOptions.isPrivate = true;
+ fpOptions.destinationFile = pbPickerFile;
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveAs=true in PB uses a separate last directory");
+ fpOptions.expectedStartingDir = pbPickerDir;
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveAs=true in Permanent PB mode ignores the incognito option");
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.privatebrowsing.autostart", true]],
+ });
+ fpOptions.isPrivate = false;
+ fpOptions.expectedStartingDir = pbPickerDir;
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveas=true reuses the non-PB last directory after private download");
+ await SpecialPowers.popPrefEnv();
+ fpOptions.isPrivate = false;
+ fpOptions.expectedStartingDir = nonPBStartingDir;
+ fpOptions.destinationFile = pickerFile;
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveAs=true does not reuse last directory when filename contains a path separator");
+ fpOptions.fileName = DEFAULT_SUBDIR + "/" + DOWNLOAD_FILENAME;
+ let destinationFile = defaultDir.clone();
+ destinationFile.append(DEFAULT_SUBDIR);
+ fpOptions.expectedStartingDir = destinationFile.clone();
+ destinationFile.append(DOWNLOAD_FILENAME);
+ fpOptions.destinationFile = destinationFile;
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveAs=false does not use the file picker");
+ fpOptions.saveAs = false;
+ await testNoFilePicker(fpOptions.saveAs);
+
+ // When saveAs is not set, the behavior should be determined by the Firefox
+ // pref that normally determines whether the "Save As" prompt should be
+ // displayed.
+ info(`Testing that the file picker is used when saveAs is not specified ` +
+ `but ${PROMPTLESS_DOWNLOAD_PREF} is disabled`);
+ fpOptions.saveAs = undefined;
+ await SpecialPowers.pushPrefEnv({"set": [
+ [PROMPTLESS_DOWNLOAD_PREF, false],
+ ]});
+ await testExpectFilePicker(fpOptions);
+
+ info(`Testing that the file picker is NOT used when saveAs is not ` +
+ `specified but ${PROMPTLESS_DOWNLOAD_PREF} is enabled`);
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.pushPrefEnv({"set": [
+ [PROMPTLESS_DOWNLOAD_PREF, true],
+ ]});
+ await testNoFilePicker(fpOptions.saveAs);
+
+ await extension.unload();
+ MockFilePicker.cleanup();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html
new file mode 100644
index 0000000000..b5fedee7ec
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html
@@ -0,0 +1,116 @@
+<!doctype html>
+<html>
+<head>
+ <title>Test downloads.download() uniquify option</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {FileUtils} = ChromeUtils.import("resource://gre/modules/FileUtils.jsm");
+
+let directory;
+
+add_task(async function setup() {
+ directory = FileUtils.getDir("TmpD", ["downloads"]);
+ directory.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`Using download directory ${directory.path}`);
+
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["browser.download.folderList", 2],
+ ["browser.download.dir", directory.path],
+ ]});
+
+ SimpleTest.registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ directory.remove(true);
+ });
+});
+
+add_task(async function test_downloads_uniquify() {
+ const file = directory.clone();
+ file.append("file_download.txt");
+
+ const unique = directory.clone();
+ unique.append("file_download(1).txt");
+
+ const {MockFilePicker} = SpecialPowers;
+ MockFilePicker.init(window);
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+
+ MockFilePicker.showCallback = fp => {
+ let file = directory.clone();
+ file.append(fp.defaultString);
+ MockFilePicker.setFiles([file]);
+ };
+
+ function background() {
+ const url = URL.createObjectURL(new Blob(["file content"]));
+ browser.test.onMessage.addListener(async (filename, saveAs) => {
+ try {
+ let id = await browser.downloads.download({
+ url,
+ filename,
+ saveAs,
+ conflictAction: "uniquify",
+ });
+ browser.downloads.onChanged.addListener(delta => {
+ if (delta.id == id && delta.state.current === "complete") {
+ browser.test.sendMessage("done", {ok: true, id});
+ }
+ });
+ } catch ({message}) {
+ browser.test.sendMessage("done", {ok: false, message});
+ }
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ const manifest = {background, manifest: {permissions: ["downloads"]}};
+ const extension = ExtensionTestUtils.loadExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ async function testUniquify(saveAs) {
+ info(`Testing conflictAction:"uniquify" with saveAs=${saveAs}`);
+
+ ok(!file.exists(), "downloaded file should have been cleaned up before test ran");
+ ok(!unique.exists(), "uniquified file should have been cleaned up before test ran");
+
+ // Test download without uniquify and create a conflicting file so we can
+ // test with uniquify.
+ extension.sendMessage("file_download.txt", saveAs);
+ let result = await extension.awaitMessage("done");
+ ok(result.ok, "downloads.download() works with saveAs");
+
+ ok(file.exists(), "the file exists.");
+ is(file.fileSize, 12, "downloaded file is the correct size");
+
+ // Now that a conflicting file exists, test the uniquify behavior
+ extension.sendMessage("file_download.txt", saveAs);
+ result = await extension.awaitMessage("done");
+ ok(result.ok, "downloads.download() works with saveAs and uniquify");
+
+ ok(unique.exists(), "the file exists.");
+ is(unique.fileSize, 12, "downloaded file is the correct size");
+
+ file.remove(false);
+ unique.remove(false);
+ }
+ await testUniquify(true);
+ await testUniquify(false);
+
+ await extension.unload();
+ MockFilePicker.cleanup();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html
new file mode 100644
index 0000000000..65bf0a50d0
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html
@@ -0,0 +1,172 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for permissions</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function makeTest(manifestPermissions, optionalPermissions, checkFetch = true) {
+ return async function() {
+ function pageScript() {
+ /* global PERMISSIONS */
+ browser.test.onMessage.addListener(async msg => {
+ if (msg == "set-cookie") {
+ try {
+ await browser.cookies.set({
+ url: "http://example.com/",
+ name: "COOKIE",
+ value: "NOM NOM",
+ });
+ browser.test.sendMessage("set-cookie.result", {success: true});
+ } catch (err) {
+ dump(`set cookie failed with ${err.message}\n`);
+ browser.test.sendMessage("set-cookie.result",
+ {success: false, message: err.message});
+ }
+ } else if (msg == "remove") {
+ browser.permissions.remove(PERMISSIONS).then(result => {
+ browser.test.sendMessage("remove.result", result);
+ });
+ } else if (msg == "request") {
+ browser.test.withHandlingUserInput(() => {
+ browser.permissions.request(PERMISSIONS).then(result => {
+ browser.test.sendMessage("request.result", result);
+ });
+ });
+ }
+ });
+
+ browser.test.sendMessage("page-ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+
+ manifest: {
+ permissions: manifestPermissions,
+ optional_permissions: [...(optionalPermissions.permissions || []),
+ ...(optionalPermissions.origins || [])],
+
+ content_scripts: [{
+ matches: ["http://mochi.test/*/file_sample.html"],
+ js: ["content_script.js"],
+ }],
+ },
+
+ files: {
+ "content_script.js": async () => {
+ let url = new URL(window.location.pathname, "http://example.com/");
+ fetch(url, {}).then(response => {
+ browser.test.sendMessage("fetch.result", response.ok);
+ }).catch(err => {
+ browser.test.sendMessage("fetch.result", false);
+ });
+ },
+
+ "page.html": `<html><head>
+ <script src="page.js"><\/script>
+ </head></html>`,
+
+ "page.js": `const PERMISSIONS = ${JSON.stringify(optionalPermissions)}; (${pageScript})();`,
+ },
+ });
+
+ await extension.startup();
+
+ function call(method) {
+ extension.sendMessage(method);
+ return extension.awaitMessage(`${method}.result`);
+ }
+
+ let base = window.location.href.replace(/^chrome:\/\/mochitests\/content/,
+ "http://mochi.test:8888");
+ let file = new URL("file_sample.html", base);
+
+ async function testContentScript() {
+ let win = window.open(file);
+ let result = await extension.awaitMessage("fetch.result");
+ win.close();
+ return result;
+ }
+
+ let url = await extension.awaitMessage("ready");
+ let win = window.open();
+ win.location.href = url;
+ await extension.awaitMessage("page-ready");
+
+ // Using the cookies API from an extension page should fail
+ let result = await call("set-cookie");
+ is(result.success, false, "setting cookie failed");
+ if (manifestPermissions.includes("cookies")) {
+ ok(/^Permission denied/.test(result.message),
+ "setting cookie failed with an appropriate error due to missing host permission");
+ } else {
+ ok(/browser\.cookies is undefined/.test(result.message),
+ "setting cookie failed since cookies API is not present");
+ }
+
+ // Making a cross-origin request from a content script should fail
+ if (checkFetch) {
+ result = await testContentScript();
+ is(result, false, "fetch() failed from content script due to lack of host permission");
+ }
+
+ result = await call("request");
+ is(result, true, "permissions.request() succeeded");
+
+ // Using the cookies API from an extension page should succeed
+ result = await call("set-cookie");
+ is(result.success, true, "setting cookie succeeded");
+
+ // Making a cross-origin request from a content script should succeed
+ if (checkFetch) {
+ result = await testContentScript();
+ is(result, true, "fetch() succeeded from content script due to lack of host permission");
+ }
+
+ // Now revoke our permissions
+ result = await call("remove");
+
+ // The cookies API should once again fail
+ result = await call("set-cookie");
+ is(result.success, false, "setting cookie failed");
+
+ // As should the cross-origin request from a content script
+ if (checkFetch) {
+ result = await testContentScript();
+ is(result, false, "fetch() failed from content script due to lack of host permission");
+ }
+
+ await extension.unload();
+ };
+}
+
+add_task(function setup() {
+ // Don't bother with prompts in this test.
+ return SpecialPowers.pushPrefEnv({
+ set: [["extensions.webextOptionalPermissionPrompts", false]],
+ });
+});
+
+const ORIGIN = "*://example.com/";
+add_task(makeTest([], {
+ permissions: ["cookies"],
+ origins: [ORIGIN],
+}));
+
+add_task(makeTest(["cookies"], {origins: [ORIGIN]}));
+add_task(makeTest([ORIGIN], {permissions: ["cookies"]}, false));
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_svg_context_fill.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_svg_context_fill.html
new file mode 100644
index 0000000000..3b022be5ec
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_svg_context_fill.html
@@ -0,0 +1,204 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for permissions</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <style>
+ img {
+ -moz-context-properties: fill;
+ fill: green;
+ }
+
+ img, div.ref {
+ width: 100px;
+ height: 100px;
+ }
+
+ div#green {
+ background: green;
+ }
+
+ div#red {
+ background: red;
+ }
+ </style>
+ <h3>Testing on: <span id="test-params"></span></h3>
+ <table>
+ <thead>
+ <tr>
+ <th>webext image</th>
+ <th>allowed ref</th>
+ <th>disallowed ref</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>
+ <img id="actual">
+ </td>
+ <td>
+ <div id="green" class="ref"></div>
+ </td>
+ <td>
+ <div id="red" class="ref"></div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+<script type="text/javascript">
+"use strict";
+
+const { TestUtils } = SpecialPowers.ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+function screenshotPage(win, elementSelector) {
+ const el = win.document.querySelector(elementSelector);
+ return TestUtils.screenshotArea(el, win);
+}
+
+async function test_moz_extension_svg_context_fill({
+ addonId,
+ isPrivileged,
+ expectAllowed,
+}) {
+ // Include current test params in the rendered html page (to be included in failure
+ // screenshots).
+ document.querySelector("#test-params").textContent = JSON.stringify({
+ addonId,
+ isPrivileged,
+ expectAllowed,
+ });
+
+ let extDefinition = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: addonId } },
+ },
+ background() {
+ browser.test.sendMessage("svg-url", browser.runtime.getURL("context-fill-fallback-red.svg"));
+ },
+ files: {
+ "context-fill-fallback-red.svg": `
+ <svg xmlns="http://www.w3.org/2000/svg" version="1.1"
+ xmlns:xlink="http://www.w3.org/1999/xlink">
+ <rect height="100%" width="100%" fill="context-fill red" />
+ </svg>
+ `,
+ },
+ }
+
+ if (isPrivileged) {
+ // isPrivileged is unused when useAddonManager is set (see ExtensionTestCommon.generate),
+ // the internal permission being tested is only added when the extension has a startupReason
+ // related to new installations and upgrades/downgrades and so the `startupReason` is set here
+ // to be able to mock the startupReason expected when useAddonManager can't be used.
+ extDefinition = {
+ ...extDefinition,
+ isPrivileged,
+ startupReason: "ADDON_INSTALL",
+ };
+ } else {
+ // useAddonManager temporary is instead used to explicitly test the other cases when the extension
+ // is not expected to be privileged.
+ extDefinition = {
+ ...extDefinition,
+ useAddonManager: "temporary",
+ };
+ }
+
+ const extension = ExtensionTestUtils.loadExtension(extDefinition);
+
+ await extension.startup();
+
+ // Set the extension url on the img element part of the
+ // comparison table defined in the html part of this test file.
+ const svgURL = await extension.awaitMessage("svg-url");
+ document.querySelector("#actual").src = svgURL;
+
+ let screenshots;
+
+ // Wait until the svg context fill has been applied
+ // (unfortunately waiting for a document reflow does
+ // not seem to be enough).
+ const expectedColor = expectAllowed ? "green" : "red";
+ await TestUtils.waitForCondition(
+ async () => {
+ const result = await screenshotPage(window, "#actual");
+ const reference = await screenshotPage(window, `#${expectedColor}`);
+ screenshots = {result, reference};
+ return result == reference;
+ },
+ `Context-fill should be ${
+ expectAllowed ? "allowed" : "disallowed"
+ } (resulting in ${expectedColor}) on "${addonId}" extension`
+ );
+
+ // At least an assertion is required to prevent the test from
+ // failing.
+ is(
+ screenshots.result,
+ screenshots.reference,
+ "svg context-fill test completed, result does match reference"
+ );
+
+ await extension.unload();
+}
+
+// This test file verify that the non-standard svg context-fill feature is allowed
+// on extensions svg files coming from Mozilla-owned extensions.
+//
+// NOTE: line extension permission to use context fill is tested in test_recommendations.js
+
+add_task(async function test_allowed_on_privileged_ext() {
+ await test_moz_extension_svg_context_fill({
+ addonId: "privileged-addon@mochi.test",
+ isPrivileged: true,
+ expectAllowed: true,
+ });
+});
+
+add_task(async function test_disallowed_on_non_privileged_ext() {
+ await test_moz_extension_svg_context_fill({
+ addonId: "non-privileged-arbitrary-addon-id@mochi.test",
+ isPrivileged: false,
+ expectAllowed: false,
+ });
+});
+
+add_task(async function test_allowed_on_privileged_ext_with_mozilla_id() {
+ await test_moz_extension_svg_context_fill({
+ addonId: "privileged-addon@mozilla.org",
+ isPrivileged: true,
+ expectAllowed: true,
+ });
+
+ await test_moz_extension_svg_context_fill({
+ addonId: "privileged-addon@mozilla.com",
+ isPrivileged: true,
+ expectAllowed: true,
+ });
+});
+
+add_task(async function test_allowed_on_non_privileged_ext_with_mozilla_id() {
+ await test_moz_extension_svg_context_fill({
+ addonId: "non-privileged-addon@mozilla.org",
+ isPrivileged: false,
+ expectAllowed: true,
+ });
+
+ await test_moz_extension_svg_context_fill({
+ addonId: "non-privileged-addon@mozilla.com",
+ isPrivileged: false,
+ expectAllowed: true,
+ });
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html
new file mode 100644
index 0000000000..580ea5e793
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html
@@ -0,0 +1,98 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+var {UrlClassifierTestUtils} = ChromeUtils.import("resource://testing-common/UrlClassifierTestUtils.jsm");
+
+function tp_background(expectFail = true) {
+ fetch("https://tracking.example.com/example.txt").then(() => {
+ browser.test.assertTrue(!expectFail, "fetch received");
+ browser.test.sendMessage("done");
+ }, () => {
+ browser.test.assertTrue(expectFail, "fetch failure");
+ browser.test.sendMessage("done");
+ });
+}
+
+async function test_permission(permissions, expectFail) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions,
+ },
+ background: `(${tp_background})(${expectFail})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+}
+
+add_task(async function setup() {
+ await UrlClassifierTestUtils.addTestTrackers();
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.trackingprotection.enabled", true]],
+ });
+});
+
+// Fetch would be blocked with these tests
+add_task(async function() { await test_permission([], true); });
+add_task(async function() { await test_permission(["http://*/"], true); });
+add_task(async function() { await test_permission(["http://*.example.com/"], true); });
+add_task(async function() { await test_permission(["http://localhost/*"], true); });
+// Fetch will not be blocked if the extension has host permissions.
+add_task(async function() { await test_permission(["<all_urls>"], false); });
+add_task(async function() { await test_permission(["*://tracking.example.com/*"], false); });
+
+add_task(async function test_contentscript() {
+ function contentScript() {
+ fetch("https://tracking.example.com/example.txt").then(() => {
+ browser.test.notifyPass("fetch received");
+ }, () => {
+ browser.test.notifyFail("fetch failure");
+ });
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: ["*://tracking.example.com/*"],
+ content_scripts: [
+ {
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ };
+ const url = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_sample.html";
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+ let win = window.open(url);
+ await extension.awaitFinish();
+ win.close();
+ await extension.unload();
+});
+
+add_task(async function teardown() {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html
new file mode 100644
index 0000000000..7e876694a0
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html
@@ -0,0 +1,81 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function webnav_unresolved_uri_on_expected_URI_scheme() {
+ function background() {
+ let checkURLs;
+
+ browser.webNavigation.onCompleted.addListener(async msg => {
+ if (checkURLs.length) {
+ let expectedURL = checkURLs.shift();
+ browser.test.assertEq(expectedURL, msg.url, "Got the expected URL");
+ await browser.tabs.remove(msg.tabId);
+ browser.test.sendMessage("next");
+ }
+ });
+
+ browser.test.onMessage.addListener((name, urls) => {
+ if (name == "checkURLs") {
+ checkURLs = urls;
+ }
+ });
+
+ browser.test.sendMessage("ready", browser.runtime.getURL("/tab.html"));
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: [
+ "webNavigation",
+ ],
+ },
+ background,
+ files: {
+ "tab.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ </html>
+ `,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ let checkURLs = [
+ "resource://gre/modules/XPCOMUtils.sys.mjs",
+ "chrome://mochikit/content/tests/SimpleTest/SimpleTest.js",
+ "about:mozilla",
+ ];
+
+ let tabURL = await extension.awaitMessage("ready");
+ checkURLs.push(tabURL);
+
+ extension.sendMessage("checkURLs", checkURLs);
+
+ for (let url of checkURLs) {
+ window.open(url);
+ await extension.awaitMessage("next");
+ }
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html
new file mode 100644
index 0000000000..a9dfb0a902
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html
@@ -0,0 +1,94 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {webrequest_test} = ChromeUtils.import(SimpleTest.getTestFileURL("webrequest_test.jsm"));
+let {testFetch, testXHR} = webrequest_test;
+
+// Here we test that any requests originating from a system principal are not
+// accessible through WebRequest. text_ext_webrequest_background_events tests
+// non-system principal requests.
+
+let testExtension = {
+ manifest: {
+ permissions: [
+ "webRequest",
+ "<all_urls>",
+ ],
+ },
+ background() {
+ let eventNames = [
+ "onBeforeRequest",
+ "onBeforeSendHeaders",
+ "onSendHeaders",
+ "onHeadersReceived",
+ "onResponseStarted",
+ "onCompleted",
+ ];
+
+ function listener(name, details) {
+ // If we get anything, we failed. Removing the system principal check
+ // in ext-webrequest triggers this failure.
+ browser.test.fail(`received ${name}`);
+ }
+
+ for (let name of eventNames) {
+ browser.webRequest[name].addListener(
+ listener.bind(null, name),
+ {urls: ["https://example.com/*"]}
+ );
+ }
+ },
+};
+
+add_task(async function test_webRequest_chromeworker_events() {
+ let extension = ExtensionTestUtils.loadExtension(testExtension);
+ await extension.startup();
+ await new Promise(resolve => {
+ let worker = new ChromeWorker("webrequest_chromeworker.js");
+ worker.onmessage = event => {
+ ok("chrome worker fetch finished");
+ resolve();
+ };
+ worker.postMessage("go");
+ });
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_chromepage_events() {
+ let extension = ExtensionTestUtils.loadExtension(testExtension);
+ await extension.startup();
+ await new Promise(resolve => {
+ fetch("https://example.com/example.txt").then(() => {
+ ok("test page loaded");
+ resolve();
+ });
+ });
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_jsm_events() {
+ let extension = ExtensionTestUtils.loadExtension(testExtension);
+ await extension.startup();
+ await testFetch("https://example.com/example.txt").then(() => {
+ ok("fetch page loaded");
+ });
+ await testXHR("https://example.com/example.txt").then(() => {
+ ok("xhr page loaded");
+ });
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html
new file mode 100644
index 0000000000..19c812f59f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html
@@ -0,0 +1,89 @@
+<!doctype html>
+<html>
+<head>
+ <title>Test webRequest checks host permissions</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_webRequest_host_permissions() {
+ function background() {
+ function png(details) {
+ browser.test.sendMessage("png", details.url);
+ }
+ browser.webRequest.onBeforeRequest.addListener(png, {urls: ["*://*/*.png"]});
+ browser.test.sendMessage("ready");
+ }
+
+ const all = ExtensionTestUtils.loadExtension({background, manifest: {permissions: ["webRequest", "<all_urls>"]}});
+ const example = ExtensionTestUtils.loadExtension({background, manifest: {permissions: ["webRequest", "https://example.com/"]}});
+ const mochi_test = ExtensionTestUtils.loadExtension({background, manifest: {permissions: ["webRequest", "http://mochi.test/"]}});
+
+ await all.startup();
+ await example.startup();
+ await mochi_test.startup();
+
+ await all.awaitMessage("ready");
+ await example.awaitMessage("ready");
+ await mochi_test.awaitMessage("ready");
+
+ const win1 = window.open("https://example.com/chrome/toolkit/components/extensions/test/mochitest/file_with_images.html");
+ let urls = [await all.awaitMessage("png"),
+ await all.awaitMessage("png")];
+ ok(urls.some(url => url.endsWith("good.png")), "<all_urls> permission gets to see good.png");
+ ok((await example.awaitMessage("png")).endsWith("good.png"), "example permission sees same-origin example.com image");
+ ok(urls.some(url => url.endsWith("great.png")), "<all_urls> permission also sees great.png");
+
+ // Clear the in-memory image cache, it can prevent listeners from receiving events.
+ const imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools);
+ imgTools.getImgCacheForDocument(win1.document).clearCache(false);
+ win1.close();
+
+ const win2 = window.open("http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_with_images.html");
+ urls = [await all.awaitMessage("png"),
+ await all.awaitMessage("png")];
+ ok(urls.some(url => url.endsWith("good.png")), "<all_urls> permission gets to see good.png");
+ ok((await mochi_test.awaitMessage("png")).endsWith("great.png"), "mochi.test permission sees same-origin mochi.test image");
+ ok(urls.some(url => url.endsWith("great.png")), "<all_urls> permission also sees great.png");
+ win2.close();
+
+ await all.unload();
+ await example.unload();
+ await mochi_test.unload();
+});
+
+add_task(async function test_webRequest_filter_permissions_warning() {
+ const manifest = {
+ permissions: ["webRequest", "http://example.com/"],
+ };
+
+ async function background() {
+ await browser.webRequest.onBeforeRequest.addListener(() => {}, {urls: ["http://example.org/"]});
+ browser.test.notifyPass();
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({manifest, background});
+
+ const warning = new Promise(resolve => {
+ SimpleTest.monitorConsole(resolve, [{message: /filter doesn't overlap with host permissions/}]);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+
+ SimpleTest.endMonitorConsole();
+ await warning;
+
+ await extension.unload();
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html
new file mode 100644
index 0000000000..6a41b9cf08
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html
@@ -0,0 +1,193 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test moz-extension protocol use</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+let peakAchu;
+add_task(async function setup() {
+ peakAchu = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "<all_urls>",
+ ],
+ },
+ background() {
+ // ID for the extension in the tests. Try to observe it to ensure we cannot.
+ browser.webRequest.onBeforeRequest.addListener(details => {
+ browser.test.notifyFail(`PeakAchu onBeforeRequest ${details.url}`);
+ }, {urls: ["<all_urls>", "moz-extension://*/*"]});
+
+ browser.test.onMessage.addListener((msg, extensionUrl) => {
+ browser.test.log(`spying for ${extensionUrl}`);
+ browser.webRequest.onBeforeRequest.addListener(details => {
+ browser.test.notifyFail(`PeakAchu onBeforeRequest ${details.url}`);
+ }, {urls: [extensionUrl]});
+ });
+ },
+ });
+ await peakAchu.startup();
+});
+
+add_task(async function test_webRequest_no_mozextension_permission() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "tabs",
+ "moz-extension://c9e007e0-e518-ed4c-8202-83849981dd21/*",
+ "moz-extension://*/*",
+ ],
+ },
+ background() {
+ browser.test.notifyPass("loaded");
+ },
+ });
+
+ let messages = [
+ {message: /processing permissions\.2: Value "moz-extension:\/\/c9e007e0-e518-ed4c-8202-83849981dd21\/\*"/},
+ {message: /processing permissions\.3: Value "moz-extension:\/\/\*\/\*"/},
+ ];
+
+ let waitForConsole = new Promise(resolve => {
+ SimpleTest.monitorConsole(resolve, messages);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("loaded");
+ await extension.unload();
+
+ SimpleTest.endMonitorConsole();
+ await waitForConsole;
+});
+
+add_task(async function test_webRequest_mozextension_fetch() {
+ function background() {
+ let page = browser.runtime.getURL("fetched.html");
+ browser.webRequest.onBeforeRequest.addListener(details => {
+ browser.test.assertEq(details.url, page, "got correct url in onBeforeRequest");
+ browser.test.sendMessage("request-started");
+ }, {urls: [browser.runtime.getURL("*")]}, ["blocking"]);
+ browser.webRequest.onCompleted.addListener(details => {
+ browser.test.assertEq(details.url, page, "got correct url in onCompleted");
+ browser.test.sendMessage("request-complete");
+ }, {urls: [browser.runtime.getURL("*")]});
+
+ browser.test.onMessage.addListener((msg, data) => {
+ fetch(page).then(() => {
+ browser.test.notifyPass("fetch success");
+ browser.test.sendMessage("done");
+ }, () => {
+ browser.test.fail("fetch failed");
+ browser.test.sendMessage("done");
+ });
+ });
+ browser.test.sendMessage("extensionUrl", browser.runtime.getURL("*"));
+ }
+
+ // Use webrequest to monitor moz-extension:// requests
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "tabs",
+ "<all_urls>",
+ ],
+ },
+ files: {
+ "fetched.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <h1>moz-extension file</h1>
+ </body>
+ </html>
+ `.trim(),
+ },
+ background,
+ });
+
+ await extension.startup();
+ // send the url for this extension to the monitoring extension
+ peakAchu.sendMessage("extensionUrl", await extension.awaitMessage("extensionUrl"));
+
+ extension.sendMessage("testFetch");
+ await extension.awaitMessage("request-started");
+ await extension.awaitMessage("request-complete");
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_mozextension_tab_query() {
+ function background() {
+ browser.test.sendMessage("extensionUrl", browser.runtime.getURL("*"));
+ let page = browser.runtime.getURL("tab.html");
+
+ async function onUpdated(tabId, tabInfo, tab) {
+ if (tabInfo.status !== "complete") {
+ return;
+ }
+ browser.test.log(`tab created ${tabId} ${JSON.stringify(tabInfo)} ${tab.url}`);
+ let tabs = await browser.tabs.query({url: browser.runtime.getURL("*")});
+ browser.test.assertEq(1, tabs.length, "got one tab");
+ browser.test.assertEq(tabs.length && tabs[0].id, tab.id, "got the correct tab");
+ browser.test.assertEq(tabs.length && tabs[0].url, page, "got correct url in tab");
+ browser.tabs.remove(tabId);
+ browser.tabs.onUpdated.removeListener(onUpdated);
+ browser.test.sendMessage("tabs-done");
+ }
+ browser.tabs.onUpdated.addListener(onUpdated);
+ browser.tabs.create({url: page});
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "tabs",
+ "<all_urls>",
+ ],
+ },
+ files: {
+ "tab.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <h1>moz-extension file</h1>
+ </body>
+ </html>
+ `.trim(),
+ },
+ background,
+ });
+
+ await extension.startup();
+ peakAchu.sendMessage("extensionUrl", await extension.awaitMessage("extensionUrl"));
+ await extension.awaitMessage("tabs-done");
+ await extension.unload();
+});
+
+add_task(async function teardown() {
+ await peakAchu.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html b/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html
new file mode 100644
index 0000000000..c29b6286d9
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html
@@ -0,0 +1,58 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// Test that the default paths searched for native host manifests
+// are the ones we expect.
+add_task(async function test_default_paths() {
+ let expectUser, expectGlobal;
+ switch (AppConstants.platform) {
+ case "macosx": {
+ expectUser = PathUtils.joinRelative(
+ Services.dirsvc.get("Home", Ci.nsIFile).path,
+ "Library/Application Support/Mozilla"
+ );
+ expectGlobal = "/Library/Application Support/Mozilla";
+
+ break;
+ }
+
+ case "linux": {
+ expectUser = PathUtils.join(
+ Services.dirsvc.get("Home", Ci.nsIFile).path,
+ ".mozilla"
+ );
+
+ const libdir = AppConstants.HAVE_USR_LIB64_DIR ? "lib64" : "lib";
+ expectGlobal = PathUtils.join("/usr", libdir, "mozilla");
+ break;
+ }
+
+ default:
+ // Fixed filesystem paths are only defined for MacOS and Linux,
+ // there's nothing to test on other platforms.
+ ok(false, `This test does not apply on ${AppConstants.platform}`);
+ break;
+ }
+
+ let userDir = Services.dirsvc.get("XREUserNativeManifests", Ci.nsIFile).path;
+ is(userDir, expectUser, "user-specific native messaging directory is correct");
+
+ let globalDir = Services.dirsvc.get("XRESysNativeManifests", Ci.nsIFile).path;
+ is(globalDir, expectGlobal, "system-wide native messaing directory is correct");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_action.html b/toolkit/components/extensions/test/mochitest/test_ext_action.html
new file mode 100644
index 0000000000..1899475723
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_action.html
@@ -0,0 +1,51 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Action with MV3</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", true]],
+ });
+});
+
+add_task(async function test_action_onClicked() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ action: {},
+ },
+ background() {
+ browser.action.onClicked.addListener(async () => {
+ browser.test.notifyPass("action-clicked");
+ });
+
+ browser.test.sendMessage("background-ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ await AppTestDelegate.clickBrowserAction(window, extension);
+ await extension.awaitFinish("action-clicked");
+ await AppTestDelegate.closeBrowserAction(window, extension);
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_activityLog.html b/toolkit/components/extensions/test/mochitest/test_ext_activityLog.html
new file mode 100644
index 0000000000..c426913373
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_activityLog.html
@@ -0,0 +1,390 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension activityLog test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_api() {
+ let URL =
+ "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_sample.html";
+
+ // Test that an unspecified extension is not logged by the watcher extension.
+ let unlogged = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ manifest: {
+ browser_specific_settings: { gecko: { id: "unlogged@tests.mozilla.org" } },
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background() {
+ // This privileged test extension should not affect the webRequest
+ // data received by non-privileged extensions (See Bug 1576272).
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ return { cancel: false };
+ },
+ { urls: ["http://mochi.test/*/file_sample.html"] },
+ ["blocking"]
+ );
+ },
+ });
+ await unlogged.startup();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "watched@tests.mozilla.org" } },
+ permissions: [
+ "tabs",
+ "tabHide",
+ "storage",
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ ],
+ content_scripts: [
+ {
+ matches: ["http://mochi.test/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+ },
+ files: {
+ "content_script.js": () => {
+ browser.test.sendMessage("content_script");
+ },
+ "registered_script.js": () => {
+ browser.test.sendMessage("registered_script");
+ },
+ },
+ async background() {
+ let listen = () => {};
+ async function runTest() {
+ // Test activity for a child function call.
+ browser.test.assertEq(
+ undefined,
+ browser.activityLog,
+ "activityLog requires permission"
+ );
+
+ // Test a child event manager.
+ browser.storage.onChanged.addListener(listen);
+ browser.storage.onChanged.removeListener(listen);
+
+ // Test a parent event manager.
+ let webRequestListener = details => {
+ browser.webRequest.onBeforeRequest.removeListener(webRequestListener);
+ return { cancel: false };
+ };
+ browser.webRequest.onBeforeRequest.addListener(
+ webRequestListener,
+ { urls: ["http://mochi.test/*/file_sample.html"] },
+ ["blocking"]
+ );
+
+ // A manifest based content script is already
+ // registered, we do a dynamic registration here.
+ await browser.contentScripts.register({
+ js: [{ file: "registered_script.js" }],
+ matches: ["http://mochi.test/*/file_sample.html"],
+ runAt: "document_start",
+ });
+ browser.test.sendMessage("ready");
+ }
+ browser.test.onMessage.addListener((msg, data) => {
+ // Logging has started here so this listener is logged, but the
+ // call adding it was not. We do an additional onMessage.addListener
+ // call in the test function to validate child based event managers.
+ if (msg == "runtest") {
+ browser.test.assertTrue(true, msg);
+ runTest();
+ }
+ if (msg == "hideTab") {
+ browser.tabs.hide(data);
+ }
+ });
+ browser.test.sendMessage("url", browser.runtime.getURL(""));
+ },
+ });
+
+ async function backgroundScript(expectedUrl, extensionUrl) {
+ let expecting = [
+ // Test child-only api_call.
+ {
+ type: "api_call",
+ name: "test.assertTrue",
+ data: { args: [true, "runtest"] },
+ },
+
+ // Test child-only api_call.
+ {
+ type: "api_call",
+ name: "test.assertEq",
+ data: {
+ args: [undefined, undefined, "activityLog requires permission"],
+ },
+ },
+ // Test child addListener calls.
+ {
+ type: "api_call",
+ name: "storage.onChanged.addListener",
+ data: {
+ args: [],
+ },
+ },
+ {
+ type: "api_call",
+ name: "storage.onChanged.removeListener",
+ data: {
+ args: [],
+ },
+ },
+ // Test parent addListener calls.
+ {
+ type: "api_call",
+ name: "webRequest.onBeforeRequest.addListener",
+ data: {
+ args: [
+ {
+ incognito: null,
+ tabId: null,
+ types: null,
+ urls: ["http://mochi.test/*/file_sample.html"],
+ windowId: null,
+ },
+ ["blocking"],
+ ],
+ },
+ },
+ // Test an api that makes use of callParentAsyncFunction.
+ {
+ type: "api_call",
+ name: "contentScripts.register",
+ data: {
+ args: [
+ {
+ allFrames: null,
+ css: null,
+ excludeGlobs: null,
+ excludeMatches: null,
+ includeGlobs: null,
+ js: [
+ {
+ file: `${extensionUrl}registered_script.js`,
+ },
+ ],
+ matchAboutBlank: null,
+ matches: ["http://mochi.test/*/file_sample.html"],
+ runAt: "document_start",
+ },
+ ],
+ },
+ },
+ // Test child api_event calls.
+ {
+ type: "api_event",
+ name: "test.onMessage",
+ data: { args: ["runtest"] },
+ },
+ {
+ type: "api_call",
+ name: "test.sendMessage",
+ data: { args: ["ready"] },
+ },
+ // Test parent api_event calls.
+ {
+ type: "api_call",
+ name: "webRequest.onBeforeRequest.removeListener",
+ data: {
+ args: [],
+ },
+ },
+ {
+ type: "api_event",
+ name: "webRequest.onBeforeRequest",
+ data: {
+ args: [
+ {
+ url: expectedUrl,
+ method: "GET",
+ type: "main_frame",
+ frameId: 0,
+ parentFrameId: -1,
+ incognito: false,
+ thirdParty: false,
+ ip: null,
+ frameAncestors: [],
+ urlClassification: { firstParty: [], thirdParty: [] },
+ requestSize: 0,
+ responseSize: 0,
+ },
+ ],
+ result: {
+ cancel: false,
+ },
+ },
+ },
+ // Test manifest based content script.
+ {
+ type: "content_script",
+ name: "content_script.js",
+ data: { url: expectedUrl, tabId: 1 },
+ },
+ // registered script test
+ {
+ type: "content_script",
+ name: `${extensionUrl}registered_script.js`,
+ data: { url: expectedUrl, tabId: 1 },
+ },
+ {
+ type: "api_call",
+ name: "test.sendMessage",
+ data: { args: ["registered_script"], tabId: 1 },
+ },
+ {
+ type: "api_call",
+ name: "test.sendMessage",
+ data: { args: ["content_script"], tabId: 1 },
+ },
+ // Child api call
+ {
+ type: "api_call",
+ name: "tabs.hide",
+ data: { args: ["__TAB_ID"] },
+ },
+ {
+ type: "api_event",
+ name: "test.onMessage",
+ data: { args: ["hideTab", "__TAB_ID"] },
+ },
+ ];
+ browser.test.assertTrue(browser.activityLog, "activityLog is privileged");
+
+ // Slightly less than a normal deep equal, we want to know that the values
+ // in our expected data are the same in the actual data, but we don't care
+ // if actual data has additional data or if data is in the same order in objects.
+ // This allows us to ignore keys that may be variable, or that are set in
+ // the api with an undefined value.
+ function deepEquivalent(a, b) {
+ if (a === b) {
+ return true;
+ }
+ if (
+ typeof a != "object" ||
+ typeof b != "object" ||
+ a === null ||
+ b === null
+ ) {
+ return false;
+ }
+ for (let k in a) {
+ if (!deepEquivalent(a[k], b[k])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ let tab;
+ let handler = async details => {
+ browser.test.log(`onExtensionActivity ${JSON.stringify(details)}`);
+ let test = expecting.shift();
+ if (!test) {
+ browser.test.notifyFail(`no test for ${details.name}`);
+ }
+
+ // On multiple runs, tabId will be different. Set the current
+ // tabId where we need it.
+ if (test.data.tabId !== undefined) {
+ test.data.tabId = tab.id;
+ }
+ if (test.data.args !== undefined) {
+ test.data.args = test.data.args.map(value =>
+ value === "__TAB_ID" ? tab.id : value
+ );
+ }
+
+ browser.test.assertEq(test.type, details.type, "type matches");
+ if (test.type == "content_script") {
+ browser.test.assertTrue(
+ details.name.includes(test.name),
+ "content script name matches"
+ );
+ } else {
+ browser.test.assertEq(test.name, details.name, "name matches");
+ }
+
+ browser.test.assertTrue(
+ deepEquivalent(test.data, details.data),
+ `expected ${JSON.stringify(
+ test.data
+ )} included in actual ${JSON.stringify(details.data)}`
+ );
+ if (!expecting.length) {
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("activity");
+ }
+ };
+ browser.activityLog.onExtensionActivity.addListener(
+ handler,
+ "watched@tests.mozilla.org"
+ );
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "opentab") {
+ tab = await browser.tabs.create({ url: expectedUrl });
+ browser.test.sendMessage("tabid", tab.id);
+ }
+ if (msg === "done") {
+ browser.activityLog.onExtensionActivity.removeListener(
+ handler,
+ "watched@tests.mozilla.org"
+ );
+ }
+ });
+ }
+
+ await extension.startup();
+ let extensionUrl = await extension.awaitMessage("url");
+
+ let logger = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ manifest: {
+ browser_specific_settings: { gecko: { id: "watcher@tests.mozilla.org" } },
+ permissions: ["activityLog"],
+ },
+ background: `(${backgroundScript})("${URL}", "${extensionUrl}")`,
+ });
+ await logger.startup();
+ extension.sendMessage("runtest");
+ await extension.awaitMessage("ready");
+ logger.sendMessage("opentab");
+ let id = await logger.awaitMessage("tabid");
+
+ await Promise.all([
+ extension.awaitMessage("content_script"),
+ extension.awaitMessage("registered_script"),
+ ]);
+
+ extension.sendMessage("hideTab", id);
+ await logger.awaitFinish("activity");
+
+ // Stop watching because we get extra calls on extension shutdown
+ // such as listener removal.
+ logger.sendMessage("done");
+
+ await extension.unload();
+ await unlogged.unload();
+ await logger.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
new file mode 100644
index 0000000000..4c82a4575c
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
@@ -0,0 +1,245 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// Tests whether not too many APIs are visible by default.
+// This file is used by test_ext_all_apis.html in browser/ and mobile/android/,
+// which may modify the following variables to add or remove expected APIs.
+/* globals expectedContentApisTargetSpecific */
+/* globals expectedBackgroundApisTargetSpecific */
+
+// Generates a list of expectations.
+function generateExpectations(list) {
+ return list
+ .reduce((allApis, path) => {
+ return allApis.concat(`browser.${path}`, `chrome.${path}`);
+ }, [])
+ .sort();
+}
+
+let expectedCommonApis = [
+ "extension.getURL",
+ "extension.inIncognitoContext",
+ "extension.lastError",
+ "i18n.detectLanguage",
+ "i18n.getAcceptLanguages",
+ "i18n.getMessage",
+ "i18n.getUILanguage",
+ "runtime.OnInstalledReason",
+ "runtime.OnRestartRequiredReason",
+ "runtime.PlatformArch",
+ "runtime.PlatformOs",
+ "runtime.RequestUpdateCheckStatus",
+ "runtime.getManifest",
+ "runtime.connect",
+ "runtime.getFrameId",
+ "runtime.getURL",
+ "runtime.id",
+ "runtime.lastError",
+ "runtime.onConnect",
+ "runtime.onMessage",
+ "runtime.sendMessage",
+ // browser.test is only available in xpcshell or when
+ // Cu.isInAutomation is true.
+ "test.assertDeepEq",
+ "test.assertEq",
+ "test.assertFalse",
+ "test.assertRejects",
+ "test.assertThrows",
+ "test.assertTrue",
+ "test.fail",
+ "test.log",
+ "test.notifyFail",
+ "test.notifyPass",
+ "test.onMessage",
+ "test.sendMessage",
+ "test.succeed",
+ "test.withHandlingUserInput",
+];
+
+let expectedContentApis = [
+ ...expectedCommonApis,
+ ...expectedContentApisTargetSpecific,
+];
+
+let expectedBackgroundApis = [
+ ...expectedCommonApis,
+ ...expectedBackgroundApisTargetSpecific,
+ "contentScripts.register",
+ "experiments.APIChildScope",
+ "experiments.APIEvent",
+ "experiments.APIParentScope",
+ "extension.ViewType",
+ "extension.getBackgroundPage",
+ "extension.getViews",
+ "extension.isAllowedFileSchemeAccess",
+ "extension.isAllowedIncognitoAccess",
+ // Note: extensionTypes is not visible in Chrome.
+ "extensionTypes.CSSOrigin",
+ "extensionTypes.ImageFormat",
+ "extensionTypes.RunAt",
+ "management.ExtensionDisabledReason",
+ "management.ExtensionInstallType",
+ "management.ExtensionType",
+ "management.getSelf",
+ "management.uninstallSelf",
+ "permissions.getAll",
+ "permissions.contains",
+ "permissions.request",
+ "permissions.remove",
+ "permissions.onAdded",
+ "permissions.onRemoved",
+ "runtime.getBackgroundPage",
+ "runtime.getBrowserInfo",
+ "runtime.getPlatformInfo",
+ "runtime.onConnectExternal",
+ "runtime.onInstalled",
+ "runtime.onMessageExternal",
+ "runtime.onStartup",
+ "runtime.onSuspend",
+ "runtime.onSuspendCanceled",
+ "runtime.onUpdateAvailable",
+ "runtime.openOptionsPage",
+ "runtime.reload",
+ "runtime.setUninstallURL",
+ "theme.getCurrent",
+ "theme.onUpdated",
+ "types.LevelOfControl",
+ "types.SettingScope",
+];
+
+// APIs that are exposed to MV2 by default, but not to MV3.
+const mv2onlyBackgroundApis = new Set([
+ "extension.getURL",
+ "extension.lastError",
+ "contentScripts.register",
+ "tabs.executeScript",
+ "tabs.insertCSS",
+ "tabs.removeCSS",
+]);
+let expectedBackgroundApisMV3 = expectedBackgroundApis.filter(
+ path => !mv2onlyBackgroundApis.has(path)
+);
+
+function sendAllApis() {
+ function isEvent(key, val) {
+ if (!/^on[A-Z]/.test(key)) {
+ return false;
+ }
+ let eventKeys = [];
+ for (let prop in val) {
+ eventKeys.push(prop);
+ }
+ eventKeys = eventKeys.sort().join();
+ return eventKeys === "addListener,hasListener,removeListener";
+ }
+ // Some items are removed from the namespaces in the lazy getters after the first get. This
+ // in one case, the events namespace, leaves a namespace that is empty. Make sure we don't
+ // consider those as a part of our testing.
+ function isEmptyObject(val) {
+ return val !== null && typeof val == "object" && !Object.keys(val).length;
+ }
+ function mayRecurse(key, val) {
+ if (Object.keys(val).filter(k => !/^[A-Z\-0-9_]+$/.test(k)).length === 0) {
+ // Don't recurse on constants and empty objects.
+ return false;
+ }
+ return !isEvent(key, val);
+ }
+
+ let results = [];
+ function diveDeeper(path, obj) {
+ for (const [key, val] of Object.entries(obj)) {
+ if (typeof val == "object" && val !== null && mayRecurse(key, val)) {
+ diveDeeper(`${path}.${key}`, val);
+ } else if (val !== undefined && !isEmptyObject(val)) {
+ results.push(`${path}.${key}`);
+ }
+ }
+ }
+ diveDeeper("browser", browser);
+ diveDeeper("chrome", chrome);
+ browser.test.sendMessage("allApis", results.sort());
+ browser.test.sendMessage("namespaces", browser === chrome);
+}
+
+add_task(async function setup() {
+ // This test enumerates all APIs and may access a deprecated API. Just log a
+ // warning instead of throwing.
+ await ExtensionTestUtils.failOnSchemaWarnings(false);
+});
+
+add_task(async function test_enumerate_content_script_apis() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://mochi.test/*/file_sample.html"],
+ js: ["contentscript.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "contentscript.js": sendAllApis,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+ let actualApis = await extension.awaitMessage("allApis");
+ win.close();
+ let expectedApis = generateExpectations(expectedContentApis);
+ isDeeply(actualApis, expectedApis, "content script APIs");
+
+ let sameness = await extension.awaitMessage("namespaces");
+ ok(sameness, "namespaces are same object");
+
+ await extension.unload();
+});
+
+add_task(async function test_enumerate_background_script_apis() {
+ let extensionData = {
+ background: sendAllApis,
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let actualApis = await extension.awaitMessage("allApis");
+ let expectedApis = generateExpectations(expectedBackgroundApis);
+ isDeeply(actualApis, expectedApis, "background script APIs");
+
+ let sameness = await extension.awaitMessage("namespaces");
+ ok(!sameness, "namespaces are different objects");
+
+ await extension.unload();
+});
+
+add_task(async function test_enumerate_background_script_apis_mv3() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", true]],
+ });
+ let extensionData = {
+ background: sendAllApis,
+ manifest: {
+ manifest_version: 3,
+
+ // Features that expose APIs in MV2, but should not do anything with MV3.
+ browser_action: {},
+ user_scripts: {},
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let actualApis = await extension.awaitMessage("allApis");
+ let expectedApis = generateExpectations(expectedBackgroundApisMV3);
+ isDeeply(actualApis, expectedApis, "background script APIs in MV3");
+
+ let sameness = await extension.awaitMessage("namespaces");
+ ok(sameness, "namespaces are same object");
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html b/toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html
new file mode 100644
index 0000000000..458dc65d99
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html
@@ -0,0 +1,401 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Async Clipboard permissions tests</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script>
+"use strict";
+
+// Bug 1479956 - On android-debug verify this test times out
+SimpleTest.requestLongerTimeout(2);
+
+/* globals ClipboardItem, clipboardWriteText, clipboardWrite, clipboardReadText, clipboardRead */
+function shared() {
+ this.clipboardWriteText = function(txt) {
+ return navigator.clipboard.writeText(txt);
+ };
+
+ this.clipboardWrite = function(items) {
+ return navigator.clipboard.write(items);
+ };
+
+ this.clipboardReadText = function() {
+ return navigator.clipboard.readText();
+ };
+
+ this.clipboardRead = function() {
+ return navigator.clipboard.read();
+ };
+}
+
+/**
+ * Clear the clipboard.
+ *
+ * This is needed because Services.clipboard.emptyClipboard() does not clear the actual system clipboard.
+ */
+function clearClipboard() {
+ if (AppConstants.platform == "android") {
+ // On android, this clears the actual system clipboard
+ SpecialPowers.Services.clipboard.emptyClipboard(SpecialPowers.Services.clipboard.kGlobalClipboard);
+ return;
+ }
+ // Need to do this hack on other platforms to clear the actual system clipboard
+ let transf = SpecialPowers.Cc["@mozilla.org/widget/transferable;1"]
+ .createInstance(SpecialPowers.Ci.nsITransferable);
+ transf.init(null);
+ // Empty transferables may cause crashes, so just add an unknown type.
+ const TYPE = "text/x-moz-place-empty";
+ transf.addDataFlavor(TYPE);
+ transf.setTransferData(TYPE, {});
+ SpecialPowers.Services.clipboard.setData(transf, null, SpecialPowers.Services.clipboard.kGlobalClipboard);
+}
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["dom.events.asyncClipboard.clipboardItem", true],
+ ]});
+});
+
+// Test that without enough permissions, we are NOT allowed to use writeText, write, read or readText in background script
+add_task(async function test_background_async_clipboard_no_permissions() {
+ function backgroundScript() {
+ const item = new ClipboardItem({
+ "text/plain": new Blob(["HI"], {type: "text/plain"})
+ });
+ browser.test.assertRejects(
+ clipboardRead(),
+ (err) => err === undefined,
+ "Read should be denied without permission"
+ );
+ browser.test.assertRejects(
+ clipboardWrite([item]),
+ "Clipboard write was blocked due to lack of user activation.",
+ "Write should be denied without permission"
+ );
+ browser.test.assertRejects(
+ clipboardWriteText("blabla"),
+ "Clipboard write was blocked due to lack of user activation.",
+ "WriteText should be denied without permission"
+ );
+ browser.test.assertRejects(
+ clipboardReadText(),
+ (err) => err === undefined,
+ "ReadText should be denied without permission"
+ );
+ browser.test.sendMessage("ready");
+ }
+ let extensionData = {
+ background: [shared, backgroundScript],
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ await extension.unload();
+});
+
+// Test that without enough permissions, we are NOT allowed to use writeText, write, read or readText in content script
+add_task(async function test_contentscript_async_clipboard_no_permission() {
+ function contentScript() {
+ const item = new ClipboardItem({
+ "text/plain": new Blob(["HI"], {type: "text/plain"})
+ });
+ browser.test.assertRejects(
+ clipboardRead(),
+ (err) => err === undefined,
+ "Read should be denied without permission"
+ );
+ browser.test.assertRejects(
+ clipboardWrite([item]),
+ "Clipboard write was blocked due to lack of user activation.",
+ "Write should be denied without permission"
+ );
+ browser.test.assertRejects(
+ clipboardWriteText("blabla"),
+ "Clipboard write was blocked due to lack of user activation.",
+ "WriteText should be denied without permission"
+ );
+ browser.test.assertRejects(
+ clipboardReadText(),
+ (err) => err === undefined,
+ "ReadText should be denied without permission"
+ );
+ browser.test.sendMessage("ready");
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ win.close();
+ await extension.unload();
+});
+
+// Test that with enough permissions, we are allowed to use writeText in content script
+add_task(async function test_contentscript_clipboard_permission_writetext() {
+ function contentScript() {
+ let str = "HI";
+ clipboardWriteText(str).then(function() {
+ // nothing here
+ browser.test.sendMessage("ready");
+ }, function(err) {
+ browser.test.fail("WriteText promise rejected");
+ browser.test.sendMessage("ready");
+ }); // clipboardWriteText
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardWrite",
+ "clipboardRead",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ const actual = SpecialPowers.getClipboardData("text/plain");
+ is(actual, "HI", "right string copied by write");
+ win.close();
+ await extension.unload();
+});
+
+// Test that with enough permissions, we are allowed to use readText in content script
+add_task(async function test_contentscript_clipboard_permission_readtext() {
+ function contentScript() {
+ let str = "HI";
+ clipboardReadText().then(function(strData) {
+ if (strData == str) {
+ browser.test.succeed("Successfully read from clipboard");
+ } else {
+ browser.test.fail("ReadText read the wrong thing from clipboard:" + strData);
+ }
+ browser.test.sendMessage("ready");
+ }, function(err) {
+ browser.test.fail("ReadText promise rejected");
+ browser.test.sendMessage("ready");
+ }); // clipboardReadText
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardWrite",
+ "clipboardRead",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ await SimpleTest.promiseClipboardChange("HI", () => {
+ SpecialPowers.clipboardCopyString("HI");
+ }, "text/plain");
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ win.close();
+ await extension.unload();
+});
+
+// Test that with enough permissions, we are allowed to use write in content script
+add_task(async function test_contentscript_clipboard_permission_write() {
+ function contentScript() {
+ const item = new ClipboardItem({
+ "text/plain": new Blob(["HI"], {type: "text/plain"})
+ });
+ clipboardWrite([item]).then(function() {
+ // nothing here
+ browser.test.sendMessage("ready");
+ }, function(err) { // clipboardWrite promise error function
+ browser.test.fail("Write promise rejected");
+ browser.test.sendMessage("ready");
+ }); // clipboard write
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardWrite",
+ "clipboardRead",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ const actual = SpecialPowers.getClipboardData("text/plain");
+ is(actual, "HI", "right string copied by write");
+ win.close();
+ await extension.unload();
+});
+
+// Test that with enough permissions, we are allowed to use read in content script
+add_task(async function test_contentscript_clipboard_permission_read() {
+ function contentScript() {
+ clipboardRead().then(async function(items) {
+ let blob = await items[0].getType("text/plain");
+ let s = await blob.text();
+ if (s == "HELLO") {
+ browser.test.succeed("Read promise successfully read the right thing");
+ } else {
+ browser.test.fail("Read read the wrong string from clipboard:" + s);
+ }
+ browser.test.sendMessage("ready");
+ }, function(err) { // clipboardRead promise error function
+ browser.test.fail("Read promise rejected");
+ browser.test.sendMessage("ready");
+ }); // clipboard read
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardWrite",
+ "clipboardRead",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ await SimpleTest.promiseClipboardChange("HELLO", () => {
+ SpecialPowers.clipboardCopyString("HELLO");
+ }, "text/plain");
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ win.close();
+ await extension.unload();
+});
+
+// Test that performing readText(...) when the clipboard is empty returns an empty string
+add_task(async function test_contentscript_clipboard_nocontents_readtext() {
+ function contentScript() {
+ clipboardReadText().then(function(strData) {
+ if (strData == "") {
+ browser.test.succeed("ReadText successfully read correct thing from an empty clipboard");
+ } else {
+ browser.test.fail("ReadText should have read an empty string, but read:" + strData);
+ }
+ browser.test.sendMessage("ready");
+ }, function(err) {
+ browser.test.fail("ReadText promise rejected: " + err);
+ browser.test.sendMessage("ready");
+ });
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardRead",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+
+ await SimpleTest.promiseClipboardChange("", () => {
+ clearClipboard();
+ }, "text/x-moz-place-empty");
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ win.close();
+ await extension.unload();
+});
+
+// Test that performing read(...) when the clipboard is empty returns an empty ClipboardItem
+add_task(async function test_contentscript_clipboard_nocontents_read() {
+ function contentScript() {
+ clipboardRead().then(function(items) {
+ if (items[0].types.length) {
+ browser.test.fail("Read read the wrong thing from clipboard, " +
+ "ClipboardItem has this many entries: " + items[0].types.length);
+ } else {
+ browser.test.succeed("Read promise successfully resolved");
+ }
+ browser.test.sendMessage("ready");
+ }, function(err) {
+ browser.test.fail("Read promise rejected: " + err);
+ browser.test.sendMessage("ready");
+ });
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardRead",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+
+ await SimpleTest.promiseClipboardChange("", () => {
+ clearClipboard();
+ }, "text/x-moz-place-empty");
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ win.close();
+ await extension.unload();
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html b/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html
new file mode 100644
index 0000000000..e7745f08c5
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html
@@ -0,0 +1,42 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for background page canvas rendering</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_background_canvas() {
+ function background() {
+ try {
+ let canvas = document.createElement("canvas");
+
+ let context = canvas.getContext("2d");
+
+ // This ensures that we have a working PresShell, and can successfully
+ // calculate font metrics.
+ context.font = "8pt fixed";
+
+ browser.test.notifyPass("background-canvas");
+ } catch (e) {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ browser.test.notifyFail("background-canvas");
+ }
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({ background });
+
+ await extension.startup();
+ await extension.awaitFinish("background-canvas");
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_page.html b/toolkit/components/extensions/test/mochitest/test_ext_background_page.html
new file mode 100644
index 0000000000..2f4fe3b96c
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_background_page.html
@@ -0,0 +1,84 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>WebExtension test</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js" type="text/javascript"></script>
+ <link href="/tests/SimpleTest/test.css" rel="stylesheet"/>
+ </head>
+ <body>
+
+ <script type="text/javascript">
+ "use strict";
+
+ /* eslint-disable mozilla/balanced-listeners */
+
+ add_task(async function testAlertNotShownInBackgroundWindow() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: function () {
+ alert("I am an alert in the background.");
+
+ browser.test.notifyPass("alertCalled");
+ }
+ });
+
+ let consoleOpened = loadChromeScript(() => {
+ const {sendAsyncMessage, assert} = this;
+ assert.ok(!Services.wm.getEnumerator("alert:alert").hasMoreElements(), "Alerts should not be present at the start of the test.");
+
+ Services.obs.addObserver(function observer() {
+ sendAsyncMessage("web-console-created");
+ Services.obs.removeObserver(observer, "web-console-created");
+ }, "web-console-created");
+ });
+ let opened = consoleOpened.promiseOneMessage("web-console-created");
+
+ consoleMonitor.start([
+ {
+ message: /alert\(\) is not supported in background windows/
+ }, {
+ message: /I am an alert in the background/
+ }
+ ]);
+
+ await extension.startup();
+ await extension.awaitFinish("alertCalled");
+
+ let chromeScript = loadChromeScript(async () => {
+ const {assert} = this;
+ assert.ok(!Services.wm.getEnumerator("alert:alert").hasMoreElements(), "Alerts should not be present after calling alert().");
+ });
+ chromeScript.destroy();
+
+ await consoleMonitor.finished();
+
+ await opened;
+ consoleOpened.destroy();
+
+ chromeScript = loadChromeScript(async () => {
+ const {sendAsyncMessage} = this;
+ let {require} = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs");
+ require("devtools/client/framework/devtools-browser");
+ let {BrowserConsoleManager} = require("devtools/client/webconsole/browser-console-manager");
+
+ // And then double check that we have an actual browser console.
+ let haveConsole = !!BrowserConsoleManager.getBrowserConsole();
+
+ if (haveConsole) {
+ await BrowserConsoleManager.toggleBrowserConsole();
+ }
+ sendAsyncMessage("done", haveConsole);
+ });
+
+ let consoleShown = await chromeScript.promiseOneMessage("done");
+ ok(consoleShown, "console was shown");
+ chromeScript.destroy();
+
+ await extension.unload();
+ });
+ </script>
+
+ </body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_page_dpi.html b/toolkit/components/extensions/test/mochitest/test_ext_background_page_dpi.html
new file mode 100644
index 0000000000..40772402b1
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_background_page_dpi.html
@@ -0,0 +1,46 @@
+<!DOCTYPE HTML>
+<meta charset="utf-8">
+<title>DPI of background page</title>
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+<script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+<script src="head.js"></script>
+<link rel="stylesheet" href="/tests/SimpleTest/test.css">
+<script>
+"use strict";
+
+async function testDPIMatches(description) {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: function() {
+ browser.test.sendMessage("dpi", window.devicePixelRatio);
+ },
+ });
+ await extension.startup();
+ let dpi = await extension.awaitMessage("dpi");
+ await extension.unload();
+
+ // This assumes that the window is loaded in a device DPI.
+ is(
+ dpi,
+ window.devicePixelRatio,
+ `DPI in a background page should match DPI in primary chrome page ${description}`
+ );
+}
+
+add_task(async function test_dpi_simple() {
+ await testDPIMatches("by default");
+});
+
+add_task(async function test_dpi_devPixelsPerPx() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["layout.css.devPixelsPerPx", 1.5]],
+ });
+ await testDPIMatches("with devPixelsPerPx");
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_dpi_os_zoom() {
+ await SpecialPowers.pushPrefEnv({ set: [["ui.textScaleFactor", 200]] });
+ await testDPIMatches("with OS zoom");
+ await SpecialPowers.popPrefEnv();
+});
+</script>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup.html
new file mode 100644
index 0000000000..0d038da897
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup.html
@@ -0,0 +1,183 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>action.openPopup Test</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+let extensionData = {
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "open-popup@tests.mozilla.org",
+ }
+ },
+ browser_action: {
+ default_popup: "popup.html",
+ },
+ permissions: ["activeTab"]
+ },
+
+ useAddonManager: "android-only",
+};
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ "set": [
+ ["extensions.openPopupWithoutUserGesture.enabled", true],
+ ],
+ });
+});
+
+async function testActiveTabPermissions(withHandlingUserInput) {
+ const background = async function(withHandlingUserInput) {
+ let tabPromise;
+ let tabLoadedPromise = new Promise(resolve => {
+ // Wait for the tab to actually finish loading (bug 1589734)
+ browser.tabs.onUpdated.addListener(async (id, { status }) => {
+ if (id === (await tabPromise).id && status === "complete") {
+ resolve();
+ }
+ });
+ });
+ tabPromise = browser.tabs.create({ url: "https://www.example.com" });
+ tabLoadedPromise.then(() => {
+ // Once the popup opens, check if we have activeTab permission
+ browser.runtime.onMessage.addListener(async msg => {
+ if (msg === "popup-open") {
+ let tabs = await browser.tabs.query({});
+
+ browser.test.assertEq(
+ withHandlingUserInput ? 1 : 0,
+ tabs.filter((t) => typeof t.url !== "undefined").length,
+ "active tab permission only granted with user input"
+ );
+
+ await browser.tabs.remove((await tabPromise).id);
+ browser.test.sendMessage("activeTabsChecked");
+ }
+ });
+
+ if (withHandlingUserInput) {
+ browser.test.withHandlingUserInput(() => {
+ browser.browserAction.openPopup();
+ });
+ } else {
+ browser.browserAction.openPopup();
+ }
+ })
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ ...extensionData,
+
+ background: `(${background})(${withHandlingUserInput})`,
+
+ files: {
+ "popup.html": `<!DOCTYPE html><meta charset="utf-8"><script src="popup.js"><\/script>`,
+ async "popup.js"() {
+ browser.runtime.sendMessage("popup-open");
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("activeTabsChecked");
+ await extension.unload();
+}
+
+add_task(async function test_browserAction_openPopup_activeTab() {
+ await testActiveTabPermissions(true);
+});
+
+add_task(async function test_browserAction_openPopup_non_activeTab() {
+ await testActiveTabPermissions(false);
+});
+
+add_task(async function test_browserAction_openPopup_invalid_states() {
+ let extension = ExtensionTestUtils.loadExtension({
+ ...extensionData,
+
+ background: async function() {
+ await browser.browserAction.setPopup({ popup: "" })
+ await browser.test.assertRejects(
+ browser.browserAction.openPopup(),
+ "No popup URL is set",
+ "Should throw when no URL is set"
+ );
+
+ await browser.browserAction.disable()
+ await browser.test.assertRejects(
+ browser.browserAction.openPopup(),
+ "Popup is disabled",
+ "Should throw when disabled"
+ );
+
+ browser.test.notifyPass("invalidStates");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("invalidStates");
+ await extension.unload();
+});
+
+add_task(async function test_browserAction_openPopup_no_click_event() {
+ let extension = ExtensionTestUtils.loadExtension({
+ ...extensionData,
+
+ background: async function() {
+ let clicks = 0;
+
+ browser.browserAction.onClicked.addListener(() => {
+ clicks++;
+ });
+
+ // Test with popup set
+ await browser.browserAction.openPopup();
+ browser.test.sendMessage("close-popup");
+
+ browser.test.onMessage.addListener(async (msg) => {
+ if (msg === "popup-closed") {
+ // Test without popup
+ await browser.browserAction.setPopup({ popup: "" });
+
+ await browser.test.assertRejects(
+ browser.browserAction.openPopup(),
+ "No popup URL is set",
+ "Should throw when no URL is set"
+ );
+
+ // We expect the last call to be a no-op, so there isn't really anything
+ // to wait on. Instead, check that no clicks are registered after waiting
+ // for a sufficient amount of time.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(() => {
+ browser.test.assertEq(0, clicks, "onClicked should not be called");
+ browser.test.notifyPass("noClick");
+ }, 1000);
+ }
+ });
+ },
+ });
+
+ extension.onMessage("close-popup", async () => {
+ await AppTestDelegate.closeBrowserAction(window, extension);
+ extension.sendMessage("popup-closed");
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("noClick");
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_incognito_window.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_incognito_window.html
new file mode 100644
index 0000000000..8036d97398
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_incognito_window.html
@@ -0,0 +1,151 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>action.openPopup Incognito Test</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+let extensionData = {
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "open-popup@tests.mozilla.org",
+ }
+ },
+ browser_action: {
+ default_popup: "popup.html",
+ },
+ permissions: ["activeTab"]
+ },
+
+ useAddonManager: "android-only",
+};
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ "set": [
+ ["extensions.openPopupWithoutUserGesture.enabled", true],
+ ],
+ });
+});
+
+async function getIncognitoWindow() {
+ // Since events will be limited based on incognito, we need a
+ // spanning extension to get the tab id so we can test access failure.
+
+ let windowWatcher = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs"],
+ },
+ background: function() {
+ browser.windows.create({ incognito: true }).then(({ id: windowId }) => {
+ browser.test.onMessage.addListener(async data => {
+ if (data === "close") {
+ await browser.windows.remove(windowId);
+ browser.test.sendMessage("window-closed");
+ }
+ });
+
+ browser.test.sendMessage("window-id", windowId);
+ });
+ },
+ incognitoOverride: "spanning",
+ });
+
+ await windowWatcher.startup();
+ let windowId = await windowWatcher.awaitMessage("window-id");
+
+ return {
+ windowId,
+ close: async () => {
+ windowWatcher.sendMessage("close");
+ await windowWatcher.awaitMessage("window-closed");
+ await windowWatcher.unload();
+ },
+ };
+}
+
+async function testWithIncognitoOverride(incognitoOverride) {
+ let extension = ExtensionTestUtils.loadExtension({
+ ...extensionData,
+
+ incognitoOverride,
+
+ background: async function() {
+ browser.test.onMessage.addListener(async ({ windowId, incognitoOverride }) => {
+ const openPromise = browser.browserAction.openPopup({ windowId });
+
+ if (incognitoOverride === "not_allowed") {
+ await browser.test.assertRejects(
+ openPromise,
+ /Invalid window ID/,
+ "Should prevent open popup call for incognito window"
+ );
+ } else {
+ try {
+ browser.test.assertEq(await openPromise, undefined, "openPopup resolved");
+ } catch (e) {
+ browser.test.fail(`Unexpected error: ${e}`);
+ }
+ }
+
+ browser.test.sendMessage("incognitoWindow");
+ });
+ },
+
+ files: {
+ "popup.html": `<!DOCTYPE html><meta charset="utf-8"><script src="popup.js"><\/script>`,
+ "popup.js"() {
+ browser.test.sendMessage("popup");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let incognitoWindow = await getIncognitoWindow();
+ await extension.sendMessage({ windowId: incognitoWindow.windowId, incognitoOverride });
+
+ await extension.awaitMessage("incognitoWindow");
+
+ // Wait for the popup to open - bug 1800100
+ if (incognitoOverride === "spanning") {
+ await extension.awaitMessage("popup");
+ }
+
+ await extension.unload();
+
+ await incognitoWindow.close();
+}
+
+add_task(async function test_browserAction_openPopup_incognito_window_spanning() {
+ if (AppConstants.platform == "android") {
+ // TODO bug 1372178: Cannot open private windows from an extension.
+ todo(false, "Cannot open private windows on Android");
+ return;
+ }
+
+ await testWithIncognitoOverride("spanning");
+});
+
+add_task(async function test_browserAction_openPopup_incognito_window_not_allowed() {
+ if (AppConstants.platform == "android") {
+ // TODO bug 1372178: Cannot open private windows from an extension.
+ todo(false, "Cannot open private windows on Android");
+ return;
+ }
+
+
+ await testWithIncognitoOverride("not_allowed");
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_windowId.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_windowId.html
new file mode 100644
index 0000000000..c528028901
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_windowId.html
@@ -0,0 +1,162 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>action.openPopup Window ID Test</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+let extensionData = {
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "open-popup@tests.mozilla.org",
+ }
+ },
+ browser_action: {
+ default_popup: "popup.html",
+ default_area: "navbar",
+ },
+ permissions: ["activeTab"]
+ },
+
+ useAddonManager: "android-only",
+};
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ "set": [
+ ["extensions.openPopupWithoutUserGesture.enabled", true],
+ ],
+ });
+});
+
+async function testWithWindowState(state) {
+ const background = async function(state) {
+ const originalWindow = await browser.windows.getCurrent();
+
+ let newWindowPromise;
+ const tabLoadedPromise = new Promise(resolve => {
+ browser.tabs.onUpdated.addListener(async (id, { status }, tab) => {
+ if (tab.windowId === (await newWindowPromise).id && status === "complete") {
+ resolve();
+ }
+ });
+ });
+
+ newWindowPromise = browser.windows.create({ url: "tab.html" });
+
+ browser.test.onMessage.addListener(async (msg) => {
+ if (msg === "close-window") {
+ await browser.windows.remove((await newWindowPromise).id);
+ browser.test.sendMessage("window-closed");
+ }
+ });
+
+ tabLoadedPromise.then(async () => {
+ const windowId = (await newWindowPromise).id;
+
+ switch (state) {
+ case "inactive":
+ const focusChangePromise = new Promise(resolve => {
+ browser.windows.onFocusChanged.addListener((focusedWindowId) => {
+ if (focusedWindowId === originalWindow.id) {
+ resolve();
+ }
+ })
+ });
+ await browser.windows.update(originalWindow.id, { focused: true });
+ await focusChangePromise;
+ break;
+ case "minimized":
+ await browser.windows.update(windowId, { state: "minimized" });
+ break;
+ default:
+ throw new Error(`Invalid state: ${state}`);
+ }
+
+ await browser.browserAction.openPopup({ windowId });
+ });
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ ...extensionData,
+
+ background: `(${background})(${JSON.stringify(state)})`,
+
+ files: {
+ "tab.html": "<!DOCTYPE html>",
+ "popup.html": `<!DOCTYPE html><meta charset="utf-8"><script src="popup.js"><\/script>`,
+ "popup.js"() {
+ // Small timeout to ensure the popup doesn't immediately close, which can
+ // happen when focus moves between windows
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(async () => {
+ let windows = await browser.windows.getAll();
+ let highestWindowIdIsFocused = Math.max(...windows.map((w) => w.id))
+ === windows.find((w) => w.focused).id;
+
+ browser.test.assertEq(true, highestWindowIdIsFocused, "new window is focused");
+
+ await browser.test.sendMessage("popup-open");
+
+ // Bug 1800100: Window leaks if not explicitly closed
+ window.close();
+ }, 1000);
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("popup-open");
+ await extension.sendMessage("close-window");
+ await extension.awaitMessage("window-closed");
+ await extension.unload();
+}
+
+add_task(async function test_browserAction_openPopup_window_inactive() {
+ if (AppConstants.platform == "linux") {
+ // TODO bug 1798334: Currently unreliable on linux
+ todo(false, "Unreliable on linux");
+ return;
+ }
+ await testWithWindowState("inactive");
+});
+
+add_task(async function test_browserAction_openPopup_window_minimized() {
+ if (AppConstants.platform == "linux") {
+ // TODO bug 1798334: Currently unreliable on linux
+ todo(false, "Unreliable on linux");
+ return;
+ }
+ await testWithWindowState("minimized");
+});
+
+add_task(async function test_browserAction_openPopup_invalid_window() {
+ let extension = ExtensionTestUtils.loadExtension({
+ ...extensionData,
+
+ background: async function() {
+ await browser.test.assertRejects(
+ browser.browserAction.openPopup({ windowId: Number.MAX_SAFE_INTEGER }),
+ /Invalid window ID/,
+ "Should throw for invalid window ID"
+ );
+ browser.test.notifyPass("invalidWindow");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("invalidWindow");
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_without_pref.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_without_pref.html
new file mode 100644
index 0000000000..aa7285d5f5
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_without_pref.html
@@ -0,0 +1,58 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>action.openPopup Preference Test</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+let extensionData = {
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "open-popup@tests.mozilla.org",
+ }
+ },
+ browser_action: {
+ default_popup: "popup.html",
+ }
+ },
+
+ useAddonManager: "android-only",
+};
+
+add_task(async function test_browserAction_openPopup_without_pref() {
+ await SpecialPowers.pushPrefEnv({
+ "set": [
+ ["extensions.openPopupWithoutUserGesture.enabled", false],
+ ],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ ...extensionData,
+
+ background: async function() {
+ await browser.test.assertRejects(
+ browser.browserAction.openPopup(),
+ "openPopup requires a user gesture",
+ "Should throw when preference is unset"
+ );
+
+ browser.test.notifyPass("withoutPref");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("withoutPref");
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html
new file mode 100644
index 0000000000..f8ea41ddab
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html
@@ -0,0 +1,159 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test browsingData.remove indexedDB</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function testIndexedDB() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ async function background() {
+ const PAGE =
+ "/tests/toolkit/components/extensions/test/mochitest/file_indexedDB.html";
+
+ let tabs = [];
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg == "cleanup") {
+ await Promise.all(tabs.map(tabId => browser.tabs.remove(tabId)));
+ browser.test.sendMessage("done");
+ return;
+ }
+
+ await browser.browsingData.remove(msg, { indexedDB: true });
+ browser.test.sendMessage("indexedDBRemoved");
+ });
+
+ // Create two tabs.
+ let tab = await browser.tabs.create({ url: `https://example.org${PAGE}` });
+ tabs.push(tab.id);
+
+ tab = await browser.tabs.create({ url: `https://example.com${PAGE}` });
+ tabs.push(tab.id);
+
+ // Create tab with cookieStoreId "firefox-container-1"
+ tab = await browser.tabs.create({ url: `https://example.net${PAGE}`, cookieStoreId: 'firefox-container-1' });
+ tabs.push(tab.id);
+ }
+
+ function contentScript() {
+ // eslint-disable-next-line mozilla/balanced-listeners
+ window.addEventListener(
+ "message",
+ msg => {
+ browser.test.sendMessage("indexedDBCreated");
+ },
+ true
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["browsingData", "tabs", "cookies"],
+ content_scripts: [
+ {
+ matches: [
+ "https://example.org/*/file_indexedDB.html",
+ "https://example.com/*/file_indexedDB.html",
+ "https://example.net/*/file_indexedDB.html",
+ ],
+ js: ["script.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("indexedDBCreated");
+ await extension.awaitMessage("indexedDBCreated");
+ await extension.awaitMessage("indexedDBCreated");
+
+ function getUsage() {
+ return new Promise(resolve => {
+ let qms = SpecialPowers.Services.qms;
+ let cb = SpecialPowers.wrapCallback(request => resolve(request.result));
+ qms.getUsage(cb);
+ });
+ }
+
+ async function getOrigins() {
+ let origins = [];
+ let result = await getUsage();
+ for (let i = 0; i < result.length; ++i) {
+ if (result[i].usage === 0) {
+ continue;
+ }
+ if (
+ result[i].origin.startsWith("https://example.org") ||
+ result[i].origin.startsWith("https://example.com") ||
+ result[i].origin.startsWith("https://example.net")
+ ) {
+ origins.push(result[i].origin);
+ }
+ }
+ return origins.sort();
+ }
+
+ let origins = await getOrigins();
+ is(origins.length, 3, "IndexedDB databases have been populated.");
+
+ // Deleting private browsing mode data is silently ignored.
+ extension.sendMessage({ cookieStoreId: "firefox-private" });
+ await extension.awaitMessage("indexedDBRemoved");
+
+ origins = await getOrigins();
+ is(origins.length, 3, "All indexedDB remains after clearing firefox-private");
+
+ // Delete by hostname
+ extension.sendMessage({ hostnames: ["example.com"] });
+ await extension.awaitMessage("indexedDBRemoved");
+
+ origins = await getOrigins();
+ is(origins.length, 2, "IndexedDB data only for only two domains left");
+ ok(origins[0].startsWith("https://example.net"), "example.net not deleted");
+ ok(origins[1].startsWith("https://example.org"), "example.org not deleted");
+
+ // TODO: Bug 1643740
+ if (AppConstants.platform != "android") {
+ // Delete by cookieStoreId
+ extension.sendMessage({ cookieStoreId: "firefox-container-1" });
+ await extension.awaitMessage("indexedDBRemoved");
+
+ origins = await getOrigins();
+ is(origins.length, 1, "IndexedDB data only for only one domain");
+ ok(origins[0].startsWith("https://example.org"), "example.org not deleted");
+ }
+
+ // Delete all
+ extension.sendMessage({});
+ await extension.awaitMessage("indexedDBRemoved");
+
+ origins = await getOrigins();
+ is(origins.length, 0, "All IndexedDB data has been removed.");
+
+ await extension.sendMessage("cleanup");
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html
new file mode 100644
index 0000000000..0b61ce341f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html
@@ -0,0 +1,323 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test browsingData.remove indexedDB</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ // make sure userContext is enabled.
+ return SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+});
+
+add_task(async function testLocalStorage() {
+ async function background() {
+ function waitForTabs() {
+ return new Promise(resolve => {
+ let tabs = {};
+
+ let listener = async (msg, { tab }) => {
+ if (msg !== "content-script-ready") {
+ return;
+ }
+
+ tabs[tab.url] = tab;
+ if (Object.keys(tabs).length == 3) {
+ browser.runtime.onMessage.removeListener(listener);
+ resolve(tabs);
+ }
+ };
+ browser.runtime.onMessage.addListener(listener);
+ });
+ }
+
+ function sendMessageToTabs(tabs, message) {
+ return Promise.all(
+ Object.values(tabs).map(tab => {
+ return browser.tabs.sendMessage(tab.id, message);
+ })
+ );
+ }
+
+ let tabs = await waitForTabs();
+
+ browser.test.assertRejects(
+ browser.browsingData.removeLocalStorage({ since: Date.now() }),
+ "Firefox does not support clearing localStorage with 'since'.",
+ "Expected error received when using unimplemented parameter 'since'."
+ );
+
+ await sendMessageToTabs(tabs, "resetLocalStorage");
+ await browser.browsingData.removeLocalStorage({
+ hostnames: ["example.com"],
+ });
+ await browser.tabs.sendMessage(tabs["https://example.com/"].id, "checkLocalStorageCleared");
+ await browser.tabs.sendMessage(tabs["https://example.net/"].id, "checkLocalStorageSet");
+
+ if (
+ SpecialPowers.Services.domStorageManager.nextGenLocalStorageEnabled ===
+ false
+ ) {
+ // This assertion fails when localStorage is using the legacy
+ // implementation (See Bug 1595431).
+ browser.test.log("Skipped assertion on nextGenLocalStorageEnabled=false");
+ } else {
+ await browser.tabs.sendMessage(tabs["https://test1.example.com/"].id, "checkLocalStorageSet");
+ }
+
+ await sendMessageToTabs(tabs, "resetLocalStorage");
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+ await browser.browsingData.removeLocalStorage({});
+ await sendMessageToTabs(tabs, "checkLocalStorageCleared");
+
+ await sendMessageToTabs(tabs, "resetLocalStorage");
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+ await browser.browsingData.remove({}, { localStorage: true });
+ await sendMessageToTabs(tabs, "checkLocalStorageCleared");
+
+ // Can only delete cookieStoreId with LSNG enabled.
+ if (SpecialPowers.Services.domStorageManager.nextGenLocalStorageEnabled) {
+ await sendMessageToTabs(tabs, "resetLocalStorage");
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+ await browser.browsingData.removeLocalStorage({
+ cookieStoreId: "firefox-container-1",
+ });
+ await browser.tabs.sendMessage(tabs["https://example.com/"].id, "checkLocalStorageSet");
+ await browser.tabs.sendMessage(tabs["https://example.net/"].id, "checkLocalStorageSet");
+
+ // TODO: containers support is lacking on GeckoView (Bug 1643740)
+ if (!navigator.userAgent.includes("Android")) {
+ await browser.tabs.sendMessage(tabs["https://test1.example.com/"].id, "checkLocalStorageCleared");
+ }
+
+ await sendMessageToTabs(tabs, "resetLocalStorage");
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+ // Hostname doesn't match, so nothing cleared.
+ await browser.browsingData.removeLocalStorage({
+ cookieStoreId: "firefox-container-1",
+ hostnames: ["example.net"],
+ });
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+
+ await sendMessageToTabs(tabs, "resetLocalStorage");
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+ // Deleting private browsing mode data is silently ignored.
+ await browser.browsingData.removeLocalStorage({
+ cookieStoreId: "firefox-private",
+ });
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+ } else {
+ await browser.test.assertRejects(
+ browser.browsingData.removeLocalStorage({
+ cookieStoreId: "firefox-container-1",
+ }),
+ "Firefox does not support clearing localStorage with 'cookieStoreId'.",
+ "removeLocalStorage with cookieStoreId requires LSNG"
+ );
+ }
+
+ // Cleanup (checkLocalStorageCleared creates empty LS databases).
+ await browser.browsingData.removeLocalStorage({});
+
+ browser.test.notifyPass("done");
+ }
+
+ function contentScript() {
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg === "resetLocalStorage") {
+ localStorage.clear();
+ localStorage.setItem("test", "test");
+ } else if (msg === "checkLocalStorageSet") {
+ browser.test.assertEq(
+ "test",
+ localStorage.getItem("test"),
+ `checkLocalStorageSet: ${location.href}`
+ );
+ } else if (msg === "checkLocalStorageCleared") {
+ browser.test.assertEq(
+ null,
+ localStorage.getItem("test"),
+ `checkLocalStorageCleared: ${location.href}`
+ );
+ }
+ });
+ browser.runtime.sendMessage("content-script-ready");
+ }
+
+ // This extension is responsible for opening tabs with a specified
+ // cookieStoreId, we use a separate extension to make sure that browsingData
+ // works without the cookies permission.
+ let openTabsExtension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ name: "Open tabs",
+ browser_specific_settings: { gecko: { id: "open-tabs@tests.mozilla.org" }, },
+ permissions: ["cookies"],
+ },
+ async background() {
+ const TABS = [
+ { url: "https://example.com" },
+ { url: "https://example.net" },
+ {
+ url: "https://test1.example.com",
+ cookieStoreId: 'firefox-container-1',
+ },
+ ];
+
+ function awaitLoad(tabId) {
+ return new Promise(resolve => {
+ browser.tabs.onUpdated.addListener(function listener(tabId_, changed, tab) {
+ if (tabId == tabId_ && changed.status == "complete") {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+ }
+
+ let tabs = [];
+ let loaded = [];
+ for (let options of TABS) {
+ let tab = await browser.tabs.create(options);
+ loaded.push(awaitLoad(tab.id));
+ tabs.push(tab);
+ }
+
+ await Promise.all(loaded);
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "cleanup") {
+ const tabIds = tabs.map(tab => tab.id);
+ let removedTabs = 0;
+ browser.tabs.onRemoved.addListener(tabId => {
+ browser.test.log(`Removing tab ${tabId}.`);
+ if (tabIds.includes(tabId)) {
+ removedTabs++;
+ if (removedTabs == tabIds.length) {
+ browser.test.sendMessage("done");
+ }
+ }
+ });
+ await browser.tabs.remove(tabIds);
+ }
+ });
+ }
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background,
+ manifest: {
+ name: "Test Extension",
+ browser_specific_settings: { gecko: { id: "localStorage@tests.mozilla.org" } },
+ permissions: ["browsingData", "tabs"],
+ content_scripts: [
+ {
+ matches: [
+ "https://example.com/",
+ "https://example.net/",
+ "https://test1.example.com/",
+ ],
+ js: ["content-script.js"],
+ run_at: "document_end",
+ },
+ ],
+ },
+ files: {
+ "content-script.js": contentScript,
+ },
+ });
+
+ await openTabsExtension.startup();
+
+ await extension.startup();
+ await extension.awaitFinish("done");
+ await extension.unload();
+
+ await openTabsExtension.sendMessage("cleanup");
+ await openTabsExtension.awaitMessage("done");
+ await openTabsExtension.unload();
+});
+
+// Verify that browsingData.removeLocalStorage doesn't break on data stored
+// in about:newtab or file principals.
+add_task(async function test_browserData_on_aboutnewtab_and_file_data() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ async background() {
+ await browser.browsingData.removeLocalStorage({}).catch(err => {
+ browser.test.fail(`${err} :: ${err.stack}`);
+ });
+ browser.test.sendMessage("done");
+ },
+ manifest: {
+ browser_specific_settings: { gecko: { id: "indexed-db-file@test.mozilla.org" } },
+ permissions: ["browsingData"],
+ },
+ });
+
+ await new Promise(resolve => {
+ const chromeScript = SpecialPowers.loadChromeScript(async () => {
+ /* eslint-env mozilla/chrome-script */
+ const { SiteDataTestUtils } = ChromeUtils.import(
+ "resource://testing-common/SiteDataTestUtils.jsm"
+ );
+ await SiteDataTestUtils.addToIndexedDB("about:newtab");
+ await SiteDataTestUtils.addToIndexedDB("file:///fake/file");
+ sendAsyncMessage("done");
+ });
+
+ chromeScript.addMessageListener("done", () => {
+ chromeScript.destroy();
+ resolve();
+ });
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_browserData_should_not_remove_extension_data() {
+ if (SpecialPowers.getBoolPref("dom.storage.enable_unsupported_legacy_implementation")) {
+ // When LSNG isn't enabled, the browsingData API does still clear
+ // all the extensions localStorage if called without a list of specific
+ // origins to clear.
+ info("Test skipped because LSNG is currently disabled");
+ return;
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ async background() {
+ window.localStorage.setItem("key", "value");
+ await browser.browsingData.removeLocalStorage({}).catch(err => {
+ browser.test.fail(`${err} :: ${err.stack}`);
+ });
+ browser.test.sendMessage("done", window.localStorage.getItem("key"));
+ },
+ manifest: {
+ browser_specific_settings: { gecko: { id: "extension-data@tests.mozilla.org" } },
+ permissions: ["browsingData"],
+ },
+ });
+
+ await extension.startup();
+ const lsValue = await extension.awaitMessage("done");
+ is(lsValue, "value", "Got the expected localStorage data");
+ await extension.unload();
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html
new file mode 100644
index 0000000000..bf4bd8fe80
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html
@@ -0,0 +1,69 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test browsingData.remove indexedDB</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// NB: Since plugins are disabled, there is never any data to clear.
+// We are really testing that these operations are no-ops.
+
+add_task(async function testPluginData() {
+ async function background() {
+ const REFERENCE_DATE = Date.now();
+ const TEST_CASES = [
+ // Clear plugin data with no since value.
+ {},
+ // Clear pluginData with recent since value.
+ { since: REFERENCE_DATE - 20000 },
+ // Clear pluginData with old since value.
+ { since: REFERENCE_DATE - 1000000 },
+ // Clear pluginData for specific hosts.
+ { hostnames: ["bar.com", "baz.com"] },
+ // Clear pluginData for no hosts.
+ { hostnames: [] },
+ ];
+
+ for (let method of ["removePluginData", "remove"]) {
+ for (let options of TEST_CASES) {
+ browser.test.log(`Testing ${method} with ${JSON.stringify(options)}`);
+ if (method == "removePluginData") {
+ await browser.browsingData.removePluginData(options);
+ } else {
+ await browser.browsingData.remove(options, { pluginData: true });
+ }
+ }
+ }
+
+ browser.test.sendMessage("done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["tabs", "browsingData"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+
+ // This test has no assertions because it's only meant to check that we don't
+ // throw when calling removePluginData and remove with pluginData: true.
+ ok(true, "dummy check");
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html
new file mode 100644
index 0000000000..900546f32c
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html
@@ -0,0 +1,141 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test browsingData.remove indexedDB</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const { TestUtils } = SpecialPowers.ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.serviceWorkers.exemptFromPerDomainMax", true],
+ ["dom.serviceWorkers.enabled", true],
+ ["dom.serviceWorkers.testing.enabled", true],
+ ],
+ });
+});
+
+add_task(async function testServiceWorkers() {
+ async function background() {
+ const PAGE =
+ "/tests/toolkit/components/extensions/test/mochitest/file_serviceWorker.html";
+
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.sendMessage("serviceWorkerRegistered");
+ });
+
+ let tabs = [];
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg == "cleanup") {
+ await browser.tabs.remove(tabs.map(tab => tab.id));
+ browser.test.sendMessage("done");
+ return;
+ }
+
+ await browser.browsingData.remove(
+ { hostnames: msg.hostnames },
+ { serviceWorkers: true }
+ );
+ browser.test.sendMessage("serviceWorkersRemoved");
+ });
+
+ // Create two serviceWorkers.
+ let tab = await browser.tabs.create({ url: `http://mochi.test:8888${PAGE}` });
+ tabs.push(tab);
+
+ tab = await browser.tabs.create({ url: `https://example.com${PAGE}` });
+ tabs.push(tab);
+ }
+
+ function contentScript() {
+ // eslint-disable-next-line mozilla/balanced-listeners
+ window.addEventListener(
+ "message",
+ msg => {
+ if (msg.data == "serviceWorkerRegistered") {
+ browser.runtime.sendMessage("serviceWorkerRegistered");
+ }
+ },
+ true
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["browsingData", "tabs"],
+ content_scripts: [
+ {
+ matches: [
+ "http://mochi.test/*/file_serviceWorker.html",
+ "https://example.com/*/file_serviceWorker.html",
+ ],
+ js: ["script.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("serviceWorkerRegistered");
+ await extension.awaitMessage("serviceWorkerRegistered");
+
+ // Even though we await the registrations by waiting for the messages,
+ // sometimes the serviceWorkers are still not registered at this point.
+ async function getRegistrations(count) {
+ await TestUtils.waitForCondition(
+ async () => (await SpecialPowers.registeredServiceWorkers()).length === count,
+ `Wait for ${count} service workers to be registered`
+ );
+ return SpecialPowers.registeredServiceWorkers();
+ }
+
+ let serviceWorkers = await getRegistrations(2);
+ is(serviceWorkers.length, 2, "ServiceWorkers have been registered.");
+
+ extension.sendMessage({ hostnames: ["example.com"] });
+ await extension.awaitMessage("serviceWorkersRemoved");
+
+ serviceWorkers = await getRegistrations(1);
+ is(
+ serviceWorkers.length,
+ 1,
+ "ServiceWorkers for example.com have been removed."
+ );
+
+ let { scriptSpec } = serviceWorkers[0];
+ dump(`Service worker spec: ${scriptSpec}`);
+ ok(scriptSpec.startsWith("http://mochi.test:8888/"),
+ "ServiceWorkers for example.com have been removed.");
+
+ extension.sendMessage({});
+ await extension.awaitMessage("serviceWorkersRemoved");
+
+ serviceWorkers = await getRegistrations(0);
+ is(serviceWorkers.length, 0, "All ServiceWorkers have been removed.");
+
+ extension.sendMessage("cleanup");
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html
new file mode 100644
index 0000000000..11c690e5bf
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html
@@ -0,0 +1,65 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test browsingData.settings</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const SETTINGS_LIST = [
+ "cache",
+ "cookies",
+ "history",
+ "formData",
+ "downloads",
+].sort();
+
+add_task(async function testSettings() {
+ async function background() {
+ browser.browsingData.settings().then(settings => {
+ browser.test.sendMessage("settings", settings);
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["browsingData"],
+ },
+ });
+
+ await extension.startup();
+ let settings = await extension.awaitMessage("settings");
+
+ // Verify that we get the keys back we expect.
+ isDeeply(
+ Object.entries(settings.dataToRemove)
+ .filter(([key, value]) => value)
+ .map(([key, value]) => key)
+ .sort(),
+ SETTINGS_LIST,
+ "dataToRemove contains expected properties."
+ );
+ isDeeply(
+ Object.entries(settings.dataRemovalPermitted)
+ .filter(([key, value]) => value)
+ .map(([key, value]) => key)
+ .sort(),
+ SETTINGS_LIST,
+ "dataToRemove contains expected properties."
+ );
+ is("since" in settings.options, true, "options contains |since|");
+
+ await extension.unload();
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html b/toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html
new file mode 100644
index 0000000000..7116d03235
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html
@@ -0,0 +1,64 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <script type="text/javascript" src="head_cookies.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.resistFingerprinting", true]],
+ });
+});
+
+add_task(async function test_contentscript() {
+ function contentScript() {
+ let canvas = document.createElement("canvas");
+ canvas.width = canvas.height = "100";
+
+ let ctx = canvas.getContext("2d");
+ ctx.fillStyle = "green";
+ ctx.fillRect(0, 0, 100, 100);
+ let data = ctx.getImageData(0, 0, 100, 100);
+
+ browser.test.sendMessage("data-color", data.data[1]);
+ }
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ };
+ const url = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_sample.html";
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+ let win = window.open(url);
+ let color = await extension.awaitMessage("data-color");
+ is(color, 128, "Got correct pixel data for green");
+ win.close();
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html b/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html
new file mode 100644
index 0000000000..77ac767391
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html
@@ -0,0 +1,210 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Clipboard permissions tests</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script>
+"use strict";
+
+/* globals doCopy, doPaste */
+function shared() {
+ let field = document.createElement("textarea");
+ document.body.appendChild(field);
+ field.contentEditable = true;
+
+ this.doCopy = function(txt) {
+ field.value = txt;
+ field.select();
+ return document.execCommand("copy");
+ };
+
+ this.doPaste = function() {
+ field.select();
+ return document.execCommand("paste") && field.value;
+ };
+}
+
+add_task(async function test_background_clipboard_permissions() {
+ function backgroundScript() {
+ browser.test.assertEq(false, doCopy("whatever"),
+ "copy should be denied without permission");
+ browser.test.assertEq(false, doPaste(),
+ "paste should be denied without permission");
+ browser.test.sendMessage("ready");
+ }
+ let extensionData = {
+ background: [shared, backgroundScript],
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitMessage("ready");
+
+ await extension.unload();
+});
+
+add_task(async function test_background_clipboard_copy() {
+ function backgroundScript() {
+ browser.test.onMessage.addListener(txt => {
+ browser.test.assertEq(true, doCopy(txt),
+ "copy should be allowed with permission");
+ });
+ browser.test.sendMessage("ready");
+ }
+ let extensionData = {
+ background: `(${shared})();(${backgroundScript})();`,
+ manifest: {
+ permissions: [
+ "clipboardWrite",
+ ],
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ const DUMMY_STR = "dummy string to copy";
+ await new Promise(resolve => {
+ SimpleTest.waitForClipboard(DUMMY_STR, () => {
+ extension.sendMessage(DUMMY_STR);
+ }, resolve, resolve);
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_contentscript_clipboard_permissions() {
+ function contentScript() {
+ browser.test.assertEq(false, doCopy("whatever"),
+ "copy should be denied without permission");
+ browser.test.assertEq(false, doPaste(),
+ "paste should be denied without permission");
+ browser.test.sendMessage("ready");
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["http://mochi.test/*/file_sample.html"],
+ }],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+ await extension.awaitMessage("ready");
+ win.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_contentscript_clipboard_copy() {
+ function contentScript() {
+ browser.test.onMessage.addListener(txt => {
+ browser.test.assertEq(true, doCopy(txt),
+ "copy should be allowed with permission");
+ });
+ browser.test.sendMessage("ready");
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["http://mochi.test/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardWrite",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+ await extension.awaitMessage("ready");
+
+ const DUMMY_STR = "dummy string to copy in content script";
+ await new Promise(resolve => {
+ SimpleTest.waitForClipboard(DUMMY_STR, () => {
+ extension.sendMessage(DUMMY_STR);
+ }, resolve, resolve);
+ });
+
+ win.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_contentscript_clipboard_paste() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "clipboardRead",
+ ],
+ content_scripts: [{
+ matches: ["http://mochi.test/*/file_sample.html"],
+ js: ["shared.js", "content_script.js"],
+ }],
+ },
+ files: {
+ "shared.js": shared,
+ "content_script.js": () => {
+ browser.test.sendMessage("paste", doPaste());
+ },
+ },
+ });
+
+ const STRANGE = "A Strange Thing";
+ SpecialPowers.clipboardCopyString(STRANGE);
+
+ await extension.startup();
+ const win = window.open("file_sample.html");
+
+ const paste = await extension.awaitMessage("paste");
+ is(paste, STRANGE, "the correct string was pasted");
+
+ win.close();
+ await extension.unload();
+});
+
+add_task(async function test_background_clipboard_paste() {
+ function background() {
+ browser.test.sendMessage("paste", doPaste());
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["clipboardRead"],
+ },
+ background: [shared, background],
+ });
+
+ const STRANGE = "Stranger Things";
+ SpecialPowers.clipboardCopyString(STRANGE);
+
+ await extension.startup();
+
+ const paste = await extension.awaitMessage("paste");
+ is(paste, STRANGE, "the correct string was pasted");
+
+ await extension.unload();
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html b/toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html
new file mode 100644
index 0000000000..b5d5f6764a
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html
@@ -0,0 +1,262 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Clipboard permissions tests</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script>
+"use strict";
+/**
+ * This cannot be a xpcshell test, because:
+ * - On Android, copyString of nsIClipboardHelper segfaults because
+ * widget/android/nsClipboard.cpp calls java::Clipboard::SetText, which is
+ * unavailable in xpcshell.
+ * - On Windows, the clipboard is unavailable to xpcshell.
+ */
+
+function resetClipboard() {
+ SpecialPowers.clipboardCopyString(
+ "This is the default value of the clipboard in the test.");
+}
+
+async function checkClipboardHasTestImage(imageType) {
+ async function backgroundScript(imageType) {
+ async function verifyImage(img) {
+ // Checks whether the image is a 1x1 red image.
+ browser.test.assertEq(1, img.naturalWidth, "image width should match");
+ browser.test.assertEq(1, img.naturalHeight, "image height should match");
+
+ let canvas = document.createElement("canvas");
+ canvas.width = 1;
+ canvas.height = 1;
+ let ctx = canvas.getContext("2d");
+ ctx.drawImage(img, 0, 0); // Draw without scaling.
+ let [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
+ let expectedColor;
+ if (imageType === "png") {
+ expectedColor = [255, 0, 0];
+ } else if (imageType === "jpeg") {
+ expectedColor = [254, 0, 0];
+ }
+ let {os} = await browser.runtime.getPlatformInfo();
+ if (os === "mac") {
+ // Due to https://bugzil.la/1396587, the pasted image differs from the
+ // original/expected image.
+ // Once that bug is fixed, this whole macOS-only branch can be removed.
+ if (imageType === "png") {
+ expectedColor = [255, 38, 0];
+ } else if (imageType === "jpeg") {
+ expectedColor = [255, 38, 0];
+ }
+ }
+ browser.test.assertEq(expectedColor[0], r, "pixel should be red");
+ browser.test.assertEq(expectedColor[1], g, "pixel should not contain green");
+ browser.test.assertEq(expectedColor[2], b, "pixel should not contain blue");
+ browser.test.assertEq(255, a, "pixel should be opaque");
+ }
+
+ let editable = document.body;
+ editable.contentEditable = true;
+ let file;
+ await new Promise(resolve => {
+ document.addEventListener("paste", function(event) {
+ browser.test.assertEq(1, event.clipboardData.types.length, "expected one type");
+ browser.test.assertEq("Files", event.clipboardData.types[0], "expected type");
+ browser.test.assertEq(1, event.clipboardData.files.length, "expected one file");
+
+ // After returning from the paste event, event.clipboardData is cleaned, so we
+ // have to store the file in a separate variable.
+ file = event.clipboardData.files[0];
+ resolve();
+ }, {once: true});
+
+ document.execCommand("paste"); // requires clipboardWrite permission.
+ });
+
+ // When image data is copied, its first frame is decoded and exported to the
+ // clipboard. The pasted result is always an unanimated PNG file, regardless
+ // of the input.
+ browser.test.assertEq("image/png", file.type, "expected file.type");
+
+ // event.files[0] should be an accurate representation of the input image.
+ {
+ let img = new Image();
+ await new Promise((resolve, reject) => {
+ img.onload = resolve;
+ img.onerror = () => reject(new Error(`Failed to load image ${img.src} of size ${file.size}`));
+ img.src = URL.createObjectURL(file);
+ });
+
+ await verifyImage(img);
+ }
+
+ // This confirms that an image was put on the clipboard.
+ // In contrast, when document.execCommand('copy') + clipboardData.setData
+ // is used, then the 'paste' event will also have the image data (as tested
+ // above), but the contentEditable area will be empty.
+ {
+ let imgs = editable.querySelectorAll("img");
+ browser.test.assertEq(1, imgs.length, "should have pasted one image");
+ await verifyImage(imgs[0]);
+ }
+ browser.test.sendMessage("tested image on clipboard");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})("${imageType}");`,
+ manifest: {
+ permissions: ["clipboardRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("tested image on clipboard");
+ await extension.unload();
+}
+
+add_task(async function test_without_clipboard_permission() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.assertEq(undefined, browser.clipboard,
+ "clipboard API requires the clipboardWrite permission.");
+ browser.test.notifyPass();
+ },
+ manifest: {
+ permissions: ["clipboardRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_copy_png() {
+ if (AppConstants.platform === "android") {
+ return; // Android does not support images on the clipboard.
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ // A 1x1 red PNG image.
+ let b64data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEX/AAD///9BHTQRAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg==";
+ let imageData = Uint8Array.from(atob(b64data), c => c.charCodeAt(0)).buffer;
+ await browser.clipboard.setImageData(imageData, "png");
+ browser.test.sendMessage("Called setImageData with PNG");
+ },
+ manifest: {
+ permissions: ["clipboardWrite"],
+ },
+ });
+
+ resetClipboard();
+
+ await extension.startup();
+ await extension.awaitMessage("Called setImageData with PNG");
+ await extension.unload();
+
+ await checkClipboardHasTestImage("png");
+});
+
+add_task(async function test_copy_jpeg() {
+ if (AppConstants.platform === "android") {
+ return; // Android does not support images on the clipboard.
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ // A 1x1 red JPEG image, created using: convert xc:red red.jpg.
+ // JPEG is lossy, and the red pixel value is actually #FE0000 instead of
+ // #FF0000 (also seen using: convert red.jpg text:-).
+ let b64data = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAVAQEBAAAAAAAAAAAAAAAAAAAHCf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/ADoDFU3/2Q==";
+ let imageData = Uint8Array.from(atob(b64data), c => c.charCodeAt(0)).buffer;
+ await browser.clipboard.setImageData(imageData, "jpeg");
+ browser.test.sendMessage("Called setImageData with JPEG");
+ },
+ manifest: {
+ permissions: ["clipboardWrite"],
+ },
+ });
+
+ resetClipboard();
+
+ await extension.startup();
+ await extension.awaitMessage("Called setImageData with JPEG");
+ await extension.unload();
+
+ await checkClipboardHasTestImage("jpeg");
+});
+
+add_task(async function test_copy_invalid_image() {
+ if (AppConstants.platform === "android") {
+ // Android does not support images on the clipboard.
+ return;
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ // This is a PNG image.
+ let b64data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEX/AAD///9BHTQRAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg==";
+ let pngImageData = Uint8Array.from(atob(b64data), c => c.charCodeAt(0)).buffer;
+ await browser.test.assertRejects(
+ browser.clipboard.setImageData(pngImageData, "jpeg"),
+ "Data is not a valid jpeg image",
+ "Image data that is not valid for the given type should be rejected.");
+ browser.test.sendMessage("finished invalid image");
+ },
+ manifest: {
+ permissions: ["clipboardWrite"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished invalid image");
+ await extension.unload();
+});
+
+add_task(async function test_copy_invalid_image_type() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ // setImageData expects "png" or "jpeg", but we pass "image/png" here.
+ browser.test.assertThrows(
+ () => { browser.clipboard.setImageData(new ArrayBuffer(0), "image/png"); },
+ "Type error for parameter imageType (Invalid enumeration value \"image/png\") for clipboard.setImageData.",
+ "An invalid type for setImageData should be rejected.");
+ browser.test.sendMessage("finished invalid type");
+ },
+ manifest: {
+ permissions: ["clipboardWrite"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished invalid type");
+ await extension.unload();
+});
+
+if (AppConstants.platform === "android") {
+ add_task(async function test_setImageData_unsupported_on_android() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ // Android does not support images on the clipboard,
+ // so it should not try to decode an image but fail immediately.
+ await browser.test.assertRejects(
+ browser.clipboard.setImageData(new ArrayBuffer(0), "png"),
+ "Writing images to the clipboard is not supported on Android",
+ "Should get an error when setImageData is called on Android.");
+ browser.test.sendMessage("finished unsupported setImageData");
+ },
+ manifest: {
+ permissions: ["clipboardWrite"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished unsupported setImageData");
+ await extension.unload();
+ });
+}
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html
new file mode 100644
index 0000000000..127305715f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html
@@ -0,0 +1,116 @@
+<!doctype html>
+<html>
+<head>
+ <title>Test content script match_about_blank option</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_contentscript_about_blank() {
+ const manifest = {
+ content_scripts: [
+ {
+ match_about_blank: true,
+ matches: ["http://mochi.test/*/file_with_about_blank.html", "https://example.com/*"],
+ all_frames: true,
+ css: ["all.css"],
+ js: ["all.js"],
+ }, {
+ matches: ["http://mochi.test/*/file_with_about_blank.html"],
+ css: ["mochi_without.css"],
+ js: ["mochi_without.js"],
+ all_frames: true,
+ }, {
+ match_about_blank: true,
+ matches: ["http://mochi.test/*/file_with_about_blank.html"],
+ css: ["mochi_with.css"],
+ js: ["mochi_with.js"],
+ all_frames: true,
+ },
+ ],
+ };
+
+ const files = {
+ "all.js": function() {
+ browser.runtime.sendMessage("all");
+ },
+ "all.css": `
+ body { color: red; }
+ `,
+ "mochi_without.js": function() {
+ browser.runtime.sendMessage("mochi_without");
+ },
+ "mochi_without.css": `
+ body { background: yellow; }
+ `,
+ "mochi_with.js": function() {
+ browser.runtime.sendMessage("mochi_with");
+ },
+ "mochi_with.css": `
+ body { text-align: right; }
+ `,
+ };
+
+ function background() {
+ browser.runtime.onMessage.addListener((script, {url}) => {
+ const kind = url.startsWith("about:") ? url : "top";
+ browser.test.sendMessage("script", [script, kind, url]);
+ browser.test.sendMessage(`${script}:${kind}`);
+ });
+ }
+
+ const PATH = "tests/toolkit/components/extensions/test/mochitest/file_with_about_blank.html";
+ const extension = ExtensionTestUtils.loadExtension({manifest, files, background});
+ await extension.startup();
+
+ let count = 0;
+ extension.onMessage("script", script => {
+ info(`script ran: ${script}`);
+ count++;
+ });
+
+ let win = window.open("https://example.com/" + PATH);
+ await Promise.all([
+ extension.awaitMessage("all:top"),
+ extension.awaitMessage("all:about:blank"),
+ extension.awaitMessage("all:about:srcdoc"),
+ ]);
+ is(count, 3, "exactly 3 scripts ran");
+ win.close();
+
+ win = window.open("http://mochi.test:8888/" + PATH);
+ await Promise.all([
+ extension.awaitMessage("all:top"),
+ extension.awaitMessage("all:about:blank"),
+ extension.awaitMessage("all:about:srcdoc"),
+ extension.awaitMessage("mochi_without:top"),
+ extension.awaitMessage("mochi_with:top"),
+ extension.awaitMessage("mochi_with:about:blank"),
+ extension.awaitMessage("mochi_with:about:srcdoc"),
+ ]);
+
+ let style = win.getComputedStyle(win.document.body);
+ is(style.color, "rgb(255, 0, 0)", "top window text color is red");
+ is(style.backgroundColor, "rgb(255, 255, 0)", "top window background is yellow");
+ is(style.textAlign, "right", "top window text is right-aligned");
+
+ let a_b = win.document.getElementById("a_b");
+ style = a_b.contentWindow.getComputedStyle(a_b.contentDocument.body);
+ is(style.color, "rgb(255, 0, 0)", "about:blank iframe text color is red");
+ is(style.backgroundColor, "rgba(0, 0, 0, 0)", "about:blank iframe background is transparent");
+ is(style.textAlign, "right", "about:blank text is right-aligned");
+
+ is(count, 10, "exactly 7 more scripts ran");
+ win.close();
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html
new file mode 100644
index 0000000000..96091fd959
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html
@@ -0,0 +1,711 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", true]],
+ });
+});
+
+// Create a test extension with the provided function as the background
+// script. The background script will have a few helpful functions
+// available.
+/* global awaitLoad, gatherFrameSources */
+function makeExtension({
+ background,
+ useScriptingAPI = false,
+ manifest_version = 2,
+ host_permissions,
+}) {
+ // Wait for a webNavigation.onCompleted event where the details for the
+ // loaded page match the attributes of `filter`.
+ function awaitLoad(filter) {
+ return new Promise(resolve => {
+ const listener = details => {
+ if (Object.keys(filter).every(key => details[key] === filter[key])) {
+ browser.webNavigation.onCompleted.removeListener(listener);
+ resolve();
+ }
+ };
+ browser.webNavigation.onCompleted.addListener(listener);
+ });
+ }
+
+ // Return a string with a (sorted) list of the source of all frames
+ // in the given tab into which this extension can inject scripts
+ // (ie all frames for which it has the activeTab permission).
+ // Source is the hostname for frames in http sources, or the full
+ // location href in other documents (eg about: pages)
+ const gatherFrameSources = useScriptingAPI ?
+ async function gatherFrameSources(tabid) {
+ let results = await browser.scripting.executeScript({
+ target: { tabId: tabid, allFrames: true },
+ func: () => window.location.hostname || window.location.href,
+ });
+ // Adjust `result` so that it looks like the one returned by
+ // `tabs.executeScript()`.
+ let result = results.map(res => res.result);
+
+ return String(result.sort());
+ } : async function gatherFrameSources(tabid) {
+ let result = await browser.tabs.executeScript(tabid, {
+ allFrames: true,
+ matchAboutBlank: true,
+ code: "window.location.hostname || window.location.href;",
+ });
+
+ return String(result.sort());
+ };
+
+ const permissions = ["webNavigation"];
+ if (useScriptingAPI) {
+ permissions.push("scripting");
+ }
+
+ // When host_permissions is passed, test "automatic activeTab" for ungranted
+ // host_permissions in mv3, else test with the normal activeTab permission.
+ if (!host_permissions) {
+ permissions.push("activeTab");
+ }
+
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ permissions,
+ host_permissions,
+ },
+ background: [
+ `const useScriptingAPI = ${useScriptingAPI};`,
+ `const manifest_version = ${manifest_version};`,
+ `${awaitLoad}`,
+ `${gatherFrameSources}`,
+ `${ExtensionTestCommon.serializeScript(background)}`,
+ ].join("\n")
+ });
+}
+
+// Helper function to verify that executeScript() fails without the activeTab
+// permission (or any specific origin permissions).
+const verifyNoActiveTab = async ({ useScriptingAPI, manifest_version, host_permissions }) => {
+ let extension = makeExtension({
+ async background() {
+ const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html";
+
+ let [tab] = await Promise.all([
+ browser.tabs.create({url: URL}),
+ awaitLoad({frameId: 0}),
+ ]);
+
+ await browser.test.assertRejects(
+ gatherFrameSources(tab.id),
+ /^Missing host permission/,
+ "executeScript should fail without activeTab permission"
+ );
+
+ await browser.tabs.remove(tab.id);
+
+ browser.test.notifyPass("no-active-tab");
+ },
+ useScriptingAPI,
+ manifest_version,
+ host_permissions,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("no-active-tab");
+ await extension.unload();
+};
+
+add_task(async function test_no_activeTab_tabs() {
+ await verifyNoActiveTab({ useScriptingAPI: false });
+});
+
+add_task(async function test_no_activeTab_scripting() {
+ await verifyNoActiveTab({ useScriptingAPI: true });
+});
+
+add_task(async function test_no_activeTab_scripting_mv3() {
+ await verifyNoActiveTab({
+ useScriptingAPI: true,
+ manifest_version: 3,
+ host_permissions: null,
+ });
+});
+
+add_task(async function test_no_activeTab_scripting_mv3_autoActiveTab() {
+ await verifyNoActiveTab({
+ useScriptingAPI: true,
+ manifest_version: 3,
+ host_permissions: ["http://mochi.test/"],
+ });
+});
+
+// Test helper to verify that dynamically created iframes do not get the
+// activeTab permission.
+const verifyDynamicFrames = async ({ useScriptingAPI, manifest_version, host_permissions }) => {
+ let extension = makeExtension({
+ async background() {
+ const BASE_HOST = "www.example.com";
+
+ let [tab] = await Promise.all([
+ browser.tabs.create({url: `https://${BASE_HOST}/`}),
+ awaitLoad({frameId: 0}),
+ ]);
+
+ function inject() {
+ let nframes = 4;
+ function frameLoaded() {
+ nframes--;
+ if (nframes == 0) {
+ browser.runtime.sendMessage("frames-loaded");
+ }
+ }
+
+ let frame = document.createElement("iframe");
+ frame.addEventListener("load", frameLoaded, {once: true});
+ document.body.appendChild(frame);
+
+ let div = document.createElement("div");
+ div.innerHTML = "<iframe src='https://test1.example.com/'></iframe>";
+ let framelist = div.getElementsByTagName("iframe");
+ browser.test.assertEq(1, framelist.length, "Found 1 frame inside div");
+ framelist[0].addEventListener("load", frameLoaded, {once: true});
+ document.body.appendChild(div);
+
+ let div2 = document.createElement("div");
+ div2.innerHTML = "<iframe srcdoc=\"<iframe src='https://test2.example.com/'&gt;</iframe&gt;\"></iframe>";
+ framelist = div2.getElementsByTagName("iframe");
+ browser.test.assertEq(1, framelist.length, "Found 1 frame inside div");
+ framelist[0].addEventListener("load", frameLoaded, {once: true});
+ document.body.appendChild(div2);
+
+ const URL = "https://www.example.com/tests/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html";
+
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", URL);
+ xhr.responseType = "document";
+ xhr.overrideMimeType("text/html");
+
+ xhr.addEventListener("load", () => {
+ if (xhr.readyState != 4) {
+ return;
+ }
+ if (xhr.status != 200) {
+ browser.runtime.sendMessage("error");
+ }
+
+ let frame = xhr.response.getElementById("frame");
+ browser.test.assertTrue(frame, "Found frame in response document");
+ frame.addEventListener("load", frameLoaded, {once: true});
+ document.body.appendChild(frame);
+ }, {once: true});
+ xhr.addEventListener("error", () => {
+ browser.runtime.sendMessage("error");
+ }, {once: true});
+ xhr.send();
+ }
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg !== "go") {
+ browser.test.fail(`unexpected message received: ${msg}`);
+ return;
+ }
+
+ let loadedPromise = new Promise((resolve, reject) => {
+ let listener = msg => {
+ let unlisten = () => browser.runtime.onMessage.removeListener(listener);
+ if (msg == "frames-loaded") {
+ unlisten();
+ resolve();
+ } else if (msg == "error") {
+ unlisten();
+ reject();
+ }
+ };
+ browser.runtime.onMessage.addListener(listener);
+ });
+
+ if (useScriptingAPI) {
+ await browser.scripting.executeScript({
+ target: { tabId: tab.id },
+ func: inject,
+ });
+ } else {
+ await browser.tabs.executeScript(tab.id, {
+ code: `(${inject})();`,
+ });
+ }
+
+ await loadedPromise;
+
+ let result = await gatherFrameSources(tab.id);
+
+ if (manifest_version < 3) {
+ browser.test.assertEq(
+ String([BASE_HOST]),
+ result,
+ "Script is not injected into dynamically created frames"
+ );
+ } else {
+ browser.test.assertEq(
+ String(["about:blank", "about:srcdoc", BASE_HOST]),
+ result,
+ `Script injected only into (same origin) about:blank-ish dynamically created frames`
+ );
+ }
+
+ await browser.tabs.remove(tab.id);
+
+ browser.test.notifyPass("dynamic-frames");
+ });
+
+ browser.test.sendMessage("ready", tab.id);
+ },
+ useScriptingAPI,
+ manifest_version,
+ host_permissions,
+ });
+
+ await extension.startup();
+
+ let tabId = await extension.awaitMessage("ready");
+ extension.grantActiveTab(tabId);
+
+ extension.sendMessage("go");
+ await extension.awaitFinish("dynamic-frames");
+
+ await extension.unload();
+};
+
+add_task(async function test_dynamic_frames_tabs() {
+ await verifyDynamicFrames({ useScriptingAPI: false });
+});
+
+add_task(async function test_dynamic_frames_scripting() {
+ await verifyDynamicFrames({ useScriptingAPI: true });
+});
+
+add_task(async function test_dynamic_frames_scripting_mv3() {
+ await verifyDynamicFrames({
+ useScriptingAPI: true,
+ manifest_version: 3,
+ host_permissions: null,
+ });
+});
+
+add_task(async function test_dynamic_frames_scripting_mv3_autoActiveTab() {
+ await verifyDynamicFrames({
+ useScriptingAPI: true,
+ manifest_version: 3,
+ host_permissions: ["https://www.example.com/"],
+ });
+});
+
+// Test helper to verify that an iframe created from an <iframe srcdoc> gets
+// the activeTab permission.
+const verifySrcdoc = async ({ useScriptingAPI, manifest_version, host_permissions }) => {
+ let extension = makeExtension({
+ async background() {
+ const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html";
+ const OUTER_SOURCE = "about:srcdoc";
+ const PAGE_SOURCE = "mochi.test";
+ const FRAME_SOURCE = "test1.example.com";
+
+ let [tab] = await Promise.all([
+ browser.tabs.create({url: URL}),
+ awaitLoad({frameId: 0}),
+ ]);
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg !== "go") {
+ browser.test.fail(`unexpected message received: ${msg}`);
+ return;
+ }
+
+ let result = await gatherFrameSources(tab.id);
+
+ if (manifest_version < 3) {
+ browser.test.assertEq(
+ String([OUTER_SOURCE, PAGE_SOURCE, FRAME_SOURCE]),
+ result,
+ "Script is injected into frame created from <iframe srcdoc>"
+ );
+ } else {
+ browser.test.assertEq(
+ String([OUTER_SOURCE, PAGE_SOURCE]),
+ result,
+ "Script is not injected into cross-origin frame created from <iframe srcdoc>"
+ );
+ }
+
+ await browser.tabs.remove(tab.id);
+
+ browser.test.notifyPass("srcdoc");
+ });
+
+ browser.test.sendMessage("ready", tab.id);
+ },
+ useScriptingAPI,
+ manifest_version,
+ host_permissions,
+ });
+
+ await extension.startup();
+
+ let tabId = await extension.awaitMessage("ready");
+ extension.grantActiveTab(tabId);
+
+ extension.sendMessage("go");
+ await extension.awaitFinish("srcdoc");
+
+ await extension.unload();
+};
+
+add_task(async function test_srcdoc_tabs() {
+ await verifySrcdoc({ useScriptingAPI: false });
+});
+
+add_task(async function test_srcdoc_scripting() {
+ await verifySrcdoc({ useScriptingAPI: true });
+});
+
+add_task(async function test_srcdoc_scripting_mv3() {
+ await verifySrcdoc({
+ useScriptingAPI: true,
+ manifest_version: 3,
+ host_permissions: null,
+ });
+});
+
+add_task(async function test_srcdoc_scripting_mv3_autoActiveTab() {
+ await verifySrcdoc({
+ useScriptingAPI: true,
+ manifest_version: 3,
+ host_permissions: ["http://mochi.test/"],
+ });
+});
+
+// Test helper to verify that navigating frames by setting the src attribute
+// from the parent page revokes the activeTab permission.
+const verifyNavigateBySrc = async ({ useScriptingAPI, manifest_version, host_permissions }) => {
+ let extension = makeExtension({
+ async background() {
+ const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html";
+ const PAGE_SOURCE = "mochi.test";
+ const EMPTY_SOURCE = "about:blank";
+ const FRAME_SOURCE = "test1.example.com";
+
+ let [tab] = await Promise.all([
+ browser.tabs.create({url: URL}),
+ awaitLoad({frameId: 0}),
+ ]);
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg !== "go") {
+ browser.test.fail(`unexpected message received: ${msg}`);
+ return;
+ }
+
+ let result = await gatherFrameSources(tab.id);
+ if (manifest_version < 3) {
+ browser.test.assertEq(
+ String([EMPTY_SOURCE, PAGE_SOURCE, FRAME_SOURCE]),
+ result,
+ "In original page, script is injected into base page and original frames"
+ );
+ } else {
+ browser.test.assertEq(
+ String([EMPTY_SOURCE, PAGE_SOURCE]),
+ result,
+ "In original page, script is injected into same-origin frames"
+ );
+ }
+
+ let loadedPromise = awaitLoad({tabId: tab.id});
+
+ let func = () => {
+ document.getElementById('emptyframe').src = 'http://test2.example.com/';
+ };
+
+ if (useScriptingAPI) {
+ await browser.scripting.executeScript({
+ target: { tabId: tab.id },
+ func,
+ });
+ } else {
+ await browser.tabs.executeScript(tab.id, { code: `(${func})();` });
+ }
+
+ await loadedPromise;
+
+
+ result = await gatherFrameSources(tab.id);
+ if (manifest_version < 3) {
+ browser.test.assertEq(
+ String([PAGE_SOURCE, FRAME_SOURCE]),
+ result,
+ "Script is not injected into initially empty frame after navigation"
+ );
+ } else {
+ browser.test.assertEq(
+ String([PAGE_SOURCE]),
+ result,
+ "Script is not injected into initially empty frame after navigation"
+ );
+ }
+
+ loadedPromise = awaitLoad({tabId: tab.id});
+
+ func = () => {
+ document.getElementById('regularframe').src = 'http://mochi.test:8888/';
+ };
+
+ if (useScriptingAPI) {
+ await browser.scripting.executeScript({
+ target: { tabId: tab.id },
+ func,
+ });
+ } else {
+ await browser.tabs.executeScript(tab.id, { code: `(${func})();` });
+ }
+
+ await loadedPromise;
+
+ result = await gatherFrameSources(tab.id);
+
+ if (manifest_version < 3) {
+ browser.test.assertEq(
+ String([PAGE_SOURCE]),
+ result,
+ "Script is not injected into regular frame after navigation"
+ );
+ } else {
+ browser.test.assertEq(
+ String([PAGE_SOURCE, PAGE_SOURCE]),
+ result,
+ "Script injected into frame after navigating to same-origin"
+ );
+ }
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("test-scripts");
+ });
+
+ browser.test.sendMessage("ready", tab.id);
+ },
+ useScriptingAPI,
+ manifest_version,
+ host_permissions,
+ });
+
+ await extension.startup();
+
+ let tabId = await extension.awaitMessage("ready");
+ extension.grantActiveTab(tabId);
+
+ extension.sendMessage("go");
+ await extension.awaitFinish("test-scripts");
+
+ await extension.unload();
+};
+
+add_task(async function test_navigate_by_src_tabs() {
+ await verifyNavigateBySrc({ useScriptingAPI: false });
+});
+
+add_task(async function test_navigate_by_src_scripting() {
+ await verifyNavigateBySrc({ useScriptingAPI: true });
+});
+
+add_task(async function test_navigate_by_src_scripting_mv3() {
+ await verifyNavigateBySrc({
+ useScriptingAPI: true,
+ manifest_version: 3,
+ host_permissions: null,
+ });
+});
+
+add_task(async function test_navigate_by_src_scripting_mv3_autoActiveTab() {
+ await verifyNavigateBySrc({
+ useScriptingAPI: true,
+ manifest_version: 3,
+ host_permissions: ["http://mochi.test/"],
+ });
+});
+
+// Test helper to verify that navigating frames by setting window.location from
+// inside the frame revokes the activeTab permission.
+const verifyNavigateByWindowLocation = async ({ useScriptingAPI, manifest_version, host_permissions }) => {
+ let extension = makeExtension({
+ async background() {
+ const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html";
+ const PAGE_SOURCE = "mochi.test";
+ const EMPTY_SOURCE = "about:blank";
+ const FRAME_SOURCE = "test1.example.com";
+
+ let [tab] = await Promise.all([
+ browser.tabs.create({url: URL}),
+ awaitLoad({frameId: 0}),
+ ]);
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg !== "go") {
+ browser.test.fail(`unexpected message received: ${msg}`);
+ return;
+ }
+
+ let result = await gatherFrameSources(tab.id);
+
+ if (manifest_version < 3) {
+ browser.test.assertEq(
+ String([EMPTY_SOURCE, PAGE_SOURCE, FRAME_SOURCE]),
+ result,
+ "Script initially injected into all frames"
+ );
+ } else {
+ browser.test.assertEq(
+ String([EMPTY_SOURCE, PAGE_SOURCE]),
+ result,
+ "Script initially injected into all same-origin frames"
+ );
+ }
+
+ let nframes = 0;
+ let frames = await browser.webNavigation.getAllFrames({tabId: tab.id});
+ for (let frame of frames) {
+ if (frame.parentFrameId == -1) {
+ continue;
+ }
+
+ if (manifest_version >= 3 && frame.url.includes(FRAME_SOURCE)) {
+ // In MV3, can't access cross-origin iframes from the start.
+
+ let invalidPromise = browser.scripting.executeScript({
+ target: { tabId: tab.id, frameIds: [frame.frameId] },
+ func: () => window.location.hostname,
+ });
+ await browser.test.assertRejects(
+ invalidPromise,
+ /^Missing host permission for the tab or frames/,
+ "executeScript should fail on cross-origin frame"
+ );
+
+ continue;
+ }
+
+ let loadPromise = awaitLoad({
+ tabId: tab.id,
+ frameId: frame.frameId,
+ });
+
+ let func = () => {
+ window.location.href = 'https://test2.example.com/';
+ };
+
+ if (useScriptingAPI) {
+ await browser.scripting.executeScript({
+ target: { tabId: tab.id, frameIds: [frame.frameId] },
+ func,
+ });
+ } else {
+ await browser.tabs.executeScript(tab.id, {
+ frameId: frame.frameId,
+ matchAboutBlank: true,
+ code: `(${func})();`,
+ });
+ }
+
+ await loadPromise;
+
+ let executePromise;
+ func = () => window.location.hostname;
+
+ if (useScriptingAPI) {
+ executePromise = browser.scripting.executeScript({
+ target: { tabId: tab.id, frameIds: [frame.frameId] },
+ func,
+ });
+ } else {
+ executePromise = browser.tabs.executeScript(tab.id, {
+ frameId: frame.frameId,
+ matchAboutBlank: true,
+ code: `(${func})();`,
+ });
+ }
+
+ await browser.test.assertRejects(
+ executePromise,
+ /^Missing host permission for the tab or frames/,
+ "executeScript should have failed on navigated frame"
+ );
+
+ nframes++;
+ }
+
+ if (manifest_version < 3) {
+ browser.test.assertEq(2, nframes, "Found 2 frames");
+ } else {
+ browser.test.assertEq(1, nframes, "Found 1 frame");
+ }
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("scripted-navigation");
+ });
+
+ browser.test.sendMessage("ready", tab.id);
+ },
+ useScriptingAPI,
+ manifest_version,
+ host_permissions,
+ });
+
+ await extension.startup();
+
+ let tabId = await extension.awaitMessage("ready");
+ extension.grantActiveTab(tabId);
+
+ extension.sendMessage("go");
+ await extension.awaitFinish("scripted-navigation");
+
+ await extension.unload();
+};
+
+add_task(async function test_navigate_by_window_location_tabs() {
+ await verifyNavigateByWindowLocation({ useScriptingAPI: false });
+});
+
+add_task(async function test_navigate_by_window_location_scripting() {
+ await verifyNavigateByWindowLocation({ useScriptingAPI: true });
+});
+
+add_task(async function test_navigate_by_window_location_scripting_mv3() {
+ await verifyNavigateByWindowLocation({
+ useScriptingAPI: true,
+ manifest_version: 3,
+ host_permissions: null,
+ });
+});
+
+add_task(async function test_navigate_by_window_location_scripting_mv3_autoActiveTab() {
+ await verifyNavigateByWindowLocation({
+ useScriptingAPI: true,
+ manifest_version: 3,
+ host_permissions: ["http://mochi.test/"],
+ });
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html
new file mode 100644
index 0000000000..6e2420e1c5
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html
@@ -0,0 +1,117 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script caching</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// This file defines content scripts.
+
+const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest";
+
+add_task(async function test_contentscript_cache() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ }],
+
+ permissions: ["<all_urls>", "tabs"],
+ },
+
+ async background() {
+ // Force our extension instance to be initialized for the current content process.
+ await browser.tabs.insertCSS({code: ""});
+
+ browser.test.sendMessage("origin", location.origin);
+ },
+
+ files: {
+ "content_script.js": function() {
+ browser.test.sendMessage("content-script-loaded");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let origin = await extension.awaitMessage("origin");
+ let scriptUrl = `${origin}/content_script.js`;
+
+ const { ExtensionProcessScript } = SpecialPowers.ChromeUtils.import(
+ "resource://gre/modules/ExtensionProcessScript.jsm"
+ );
+ let ext = ExtensionProcessScript.getExtensionChild(extension.id);
+
+ ext.staticScripts.expiryTimeout = 3000;
+ is(ext.staticScripts.size, 0, "Should have no cached scripts");
+
+ let win = window.open(`${BASE}/file_sample.html`);
+ await extension.awaitMessage("content-script-loaded");
+
+ if (AppConstants.platform !== "android") {
+ is(ext.staticScripts.size, 1, "Should have one cached script");
+ ok(ext.staticScripts.has(scriptUrl), "Script cache should contain script URL");
+ }
+
+ let chromeScript, chromeScriptDone;
+ let { appinfo } = SpecialPowers.Services;
+ if (appinfo.processType === appinfo.PROCESS_TYPE_CONTENT) {
+ /* globals addMessageListener, assert */
+ chromeScript = SpecialPowers.loadChromeScript(() => {
+ /* eslint-env mozilla/chrome-script */
+ addMessageListener("check-script-cache", extensionId => {
+ const { ExtensionProcessScript } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionProcessScript.jsm"
+ );
+ let ext = ExtensionProcessScript.getExtensionChild(extensionId);
+
+ if (ext && ext.staticScripts) {
+ assert.equal(ext.staticScripts.size, 0, "Should have no cached scripts in the parent process");
+ }
+
+ sendAsyncMessage("done");
+ });
+ });
+ chromeScript.sendAsyncMessage("check-script-cache", extension.id);
+ chromeScriptDone = chromeScript.promiseOneMessage("done");
+ }
+
+ SimpleTest.requestFlakyTimeout("Required to test expiry timeout");
+ await new Promise(resolve => setTimeout(resolve, 3000));
+ is(ext.staticScripts.size, 0, "Should have no cached scripts");
+
+ if (chromeScript) {
+ await chromeScriptDone;
+ chromeScript.destroy();
+ }
+
+ win.close();
+
+ win = window.open(`${BASE}/file_sample.html`);
+ await extension.awaitMessage("content-script-loaded");
+
+ is(ext.staticScripts.size, 1, "Should have one cached script");
+ ok(ext.staticScripts.has(scriptUrl));
+
+ SpecialPowers.Services.obs.notifyObservers(null, "memory-pressure", "heap-minimize");
+
+ is(ext.staticScripts.size, 0, "Should have no cached scripts after heap-minimize");
+
+ win.close();
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html
new file mode 100644
index 0000000000..8659d8c409
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html
@@ -0,0 +1,134 @@
+<!doctype html>
+<html>
+<head>
+ <title>Test content script access to canvas drawWindow()</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<script>
+"use strict";
+
+add_task(async function test_drawWindow() {
+ const permissions = [
+ "<all_urls>",
+ ];
+
+ const content_scripts = [{
+ matches: ["https://example.org/*"],
+ js: ["content_script.js"],
+ }];
+
+ const files = {
+ "content_script.js": () => {
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ try {
+ ctx.drawWindow(window, 0, 0, 10, 10, "red");
+ const {data} = ctx.getImageData(0, 0, 10, 10);
+ browser.test.sendMessage("success", data.slice(0, 3).join());
+ } catch (e) {
+ browser.test.sendMessage("error", e.message);
+ }
+ },
+ };
+
+ const first = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions,
+ content_scripts
+ },
+ files
+ });
+ const second = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts
+ },
+ files
+ });
+
+ consoleMonitor.start([{ message: /Use of drawWindow [\w\s]+ is deprecated. Use tabs.captureTab/ }]);
+
+ await first.startup();
+ await second.startup();
+
+ const win = window.open("https://example.org/tests/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html");
+
+ const colour = await first.awaitMessage("success");
+ is(colour, "255,255,153", "drawWindow() call was successful: #ff9 == rgb(255,255,153)");
+
+ const error = await second.awaitMessage("error");
+ is(error, "ctx.drawWindow is not a function", "drawWindow() method not awailable without permission");
+
+ win.close();
+ await first.unload();
+ await second.unload();
+ await consoleMonitor.finished();
+});
+
+add_task(async function test_tainted_canvas() {
+ const permissions = [
+ "<all_urls>",
+ ];
+
+ const content_scripts = [{
+ matches: ["https://example.org/*"],
+ js: ["content_script.js"],
+ }];
+
+ const files = {
+ "content_script.js": () => {
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ const img = new Image();
+
+ img.onload = function() {
+ ctx.drawImage(img, 0, 0);
+ try {
+ const png = canvas.toDataURL();
+ const {data} = ctx.getImageData(0, 0, 10, 10);
+ browser.test.sendMessage("success", {png, colour: data.slice(0, 4).join()});
+ } catch (e) {
+ browser.test.log(`Exception: ${e.message}`);
+ browser.test.sendMessage("error", e.message);
+ }
+ };
+
+ // Cross-origin image from example.com.
+ img.src = "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_image_good.png";
+ },
+ };
+
+ const first = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions,
+ content_scripts
+ },
+ files
+ });
+ const second = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts
+ },
+ files
+ });
+
+ await first.startup();
+ await second.startup();
+
+ const win = window.open("https://example.org/tests/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html");
+
+ const {png, colour} = await first.awaitMessage("success");
+ ok(png.startsWith("data:image/png;base64,"), "toDataURL() call was successful.");
+ is(colour, "0,0,0,0", "getImageData() returned the correct colour (transparent).");
+
+ const error = await second.awaitMessage("error");
+ is(error, "The operation is insecure.", "toDataURL() throws without permission.");
+
+ win.close();
+ await first.unload();
+ await second.unload();
+});
+
+</script>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html
new file mode 100644
index 0000000000..d7030258b3
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html
@@ -0,0 +1,77 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Sandbox metadata on WebExtensions ContentScripts</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_contentscript_devtools_sandbox_metadata() {
+ function contentScript() {
+ browser.runtime.sendMessage("contentScript.executed");
+ }
+
+ function background() {
+ browser.runtime.onMessage.addListener((msg) => {
+ if (msg == "contentScript.executed") {
+ browser.test.notifyPass("contentScript.executed");
+ }
+ });
+ }
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_idle",
+ },
+ ],
+ },
+
+ background,
+ files: {
+ "content_script.js": contentScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+
+ let innerWindowID = SpecialPowers.wrap(win).windowGlobalChild.innerWindowId;
+
+ await extension.awaitFinish("contentScript.executed");
+
+ const { ExtensionContent } = SpecialPowers.ChromeUtils.import(
+ "resource://gre/modules/ExtensionContent.jsm"
+ );
+
+ let res = ExtensionContent.getContentScriptGlobals(win);
+ is(res.length, 1, "Got the expected array of globals");
+ let metadata = SpecialPowers.Cu.getSandboxMetadata(res[0]) || {};
+
+ is(metadata.addonId, extension.id, "Got the expected addonId");
+ is(metadata["inner-window-id"], innerWindowID, "Got the expected inner-window-id");
+
+ await extension.unload();
+ info("extension unloaded");
+
+ res = ExtensionContent.getContentScriptGlobals(win);
+ is(res.length, 0, "No content scripts globals found once the extension is unloaded");
+
+ win.close();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html
new file mode 100644
index 0000000000..6e03c3e9cd
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html
@@ -0,0 +1,109 @@
+<!doctype html>
+<head>
+ <title>Test content script in cross-origin frame</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<script>
+"use strict";
+
+add_task(async function test_content_script_cross_origin_frame() {
+
+ const extensionData = {
+ manifest: {
+ content_scripts: [{
+ matches: ["https://example.net/*/file_sample.html"],
+ all_frames: true,
+ js: ["cs.js"],
+ }],
+ permissions: ["https://example.net/"],
+ },
+
+ background() {
+ browser.runtime.onConnect.addListener(port => {
+ port.onMessage.addListener(async num => {
+ let { tab, url, frameId } = port.sender;
+
+ browser.test.assertTrue(frameId > 0, "sender frameId is ok");
+ browser.test.assertTrue(url.endsWith("file_sample.html"), "url is ok");
+
+ let shared = await browser.tabs.executeScript(tab.id, {
+ allFrames: true,
+ code: `window.sharedVal`,
+ });
+ browser.test.assertEq(shared[0], 357, "CS runs in a shared Sandbox");
+
+ let code = "does.not.exist";
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, { allFrames: true, code }),
+ /does is not defined/,
+ "Got the expected rejection from tabs.executeScript"
+ );
+
+ code = "() => {}";
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, { allFrames: true, code }),
+ /Script .* result is non-structured-clonable data/,
+ "Got the expected rejection from tabs.executeScript"
+ );
+
+ let result = await browser.tabs.sendMessage(tab.id, num);
+ port.postMessage(result);
+ port.disconnect();
+ });
+ });
+ },
+
+ files: {
+ "cs.js"() {
+ let text = document.getElementById("test").textContent;
+ browser.test.assertEq(text, "Sample text", "CS can access page DOM");
+
+ let manifest = browser.runtime.getManifest();
+ browser.test.assertEq(manifest.version, "1.0");
+ browser.test.assertEq(manifest.name, "Generated extension");
+
+ browser.runtime.onMessage.addListener(async num => {
+ browser.test.log("content script received tabs.sendMessage");
+ return num * 3;
+ })
+
+ let response;
+ window.sharedVal = 357;
+
+ let port = browser.runtime.connect();
+ port.onMessage.addListener(num => {
+ response = num;
+ });
+ port.onDisconnect.addListener(() => {
+ browser.test.assertEq(response, 21, "Got correct response");
+ browser.test.notifyPass();
+ });
+ port.postMessage(7);
+ },
+ },
+ };
+
+ info("Load first extension");
+ let ext1 = ExtensionTestUtils.loadExtension(extensionData);
+ await ext1.startup();
+
+ info("Load a page, test content scripts in new frame with extension loaded");
+ let base = "https://example.org/tests/toolkit/components/extensions/test";
+ let win = window.open(`${base}/mochitest/file_with_xorigin_frame.html`);
+
+ await ext1.awaitFinish();
+ await ext1.unload();
+
+ info("Load second extension, test content scripts in existing frame");
+ let ext2 = ExtensionTestUtils.loadExtension(extensionData);
+ await ext2.startup();
+ await ext2.awaitFinish();
+
+ win.close();
+ await ext2.unload();
+});
+
+</script>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_getFrameId.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_getFrameId.html
new file mode 100644
index 0000000000..d679634030
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_getFrameId.html
@@ -0,0 +1,189 @@
+<!doctype html>
+<head>
+ <title>Test content script runtime.getFrameId</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<script>
+"use strict";
+
+add_task(async function test_runtime_getFrameId_invalid() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let proxy = new Proxy(window, {});
+ let proto = Object.create(window);
+
+ class FakeFrame extends HTMLIFrameElement {
+ constructor() {
+ super();
+ console.log("FakeFrame ctor"); // eslint-disable-line
+ }
+ }
+ customElements.define('fake-frame', FakeFrame, { extends: 'iframe' });
+ let custom = document.createElement("fake-frame");
+
+ let invalid = [null, 13, "blah", document.body, proxy, proto, custom];
+
+ for (let value of invalid) {
+ browser.test.assertThrows(
+ () => browser.runtime.getFrameId(value),
+ /Invalid argument/,
+ "Correct exception thrown."
+ );
+ }
+
+ let detached = document.createElement("iframe");
+ let id = browser.runtime.getFrameId(detached);
+ browser.test.assertEq(id, -1, "Detached iframe has no frameId.");
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_contentscript_runtime_getFrameId() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webNavigation", "tabs"],
+ host_permissions: ["https://example.org/"],
+ },
+
+ files: {
+ "cs.js"() {
+ browser.test.log(`Content script loaded on: ${location.href}`);
+ let parents = {};
+
+ // Recursivelly walk descendant frames and get parent frameIds.
+ function visit(win) {
+ let frameId = browser.runtime.getFrameId(win);
+ let parentId = browser.runtime.getFrameId(win.parent);
+ parents[frameId] = (win.parent != win) ? parentId : -1;
+
+ try {
+ let frameEl = browser.runtime.getFrameId(win.frameElement);
+ browser.test.assertEq(frameId, frameEl, "frameElement id correct");
+ } catch (e) {
+ // Can't access a cross-origin .frameElement.
+ }
+
+ for (let i = 0; i < win.frames.length; i++) {
+ visit(win.frames[i]);
+ }
+ }
+ visit(window);
+
+ // Add the <embed> frame if it exists.
+ let embed = document.querySelector("embed");
+ if (embed) {
+ let id = browser.runtime.getFrameId(embed);
+ parents[id] = 0;
+ }
+
+ // Add the <object> frame if it exists.
+ let object = document.querySelector("object");
+ if (object) {
+ let id = browser.runtime.getFrameId(object);
+ parents[id] = 0;
+ }
+
+ browser.test.log(`Parents tree: ${JSON.stringify(parents)}`);
+ return parents;
+ },
+
+ async "closedPopup.js"() {
+ let popup = window.open("https://example.org/?popup");
+ popup.close();
+ for (let i = 0; i < 100; i++) {
+ await new Promise(r => setTimeout(r, 50));
+ try {
+ popup.blur();
+ } catch(e) {
+ if (e.message === "can't access dead object") {
+ browser.test.assertThrows(
+ () => browser.runtime.getFrameId(popup),
+ /An exception was thrown/,
+ "Passing a dead object throws."
+ );
+ browser.test.sendMessage("done");
+ return;
+ }
+ }
+ }
+ browser.test.fail("Timed out while waiting for popup to close.");
+ },
+ "closedPopup.html": `
+ <!doctype html>
+ <meta charset="utf-8">
+ <script src="closedPopup.js"><\/script>
+ `,
+ },
+
+ async background() {
+ let base = "https://example.org/tests/toolkit/components/extensions/test/mochitest";
+ let files = {
+ "file_contains_iframe.html": 2,
+ "file_WebNavigation_page1.html": 2,
+ "file_with_xorigin_frame.html": 2,
+ // Contains all of the above.
+ "file_with_subframes_and_embed.html": 9,
+ };
+
+ for (let [file, count] of Object.entries(files)) {
+ let tab;
+ let completed = new Promise(resolve => {
+ browser.webNavigation.onCompleted.addListener(function cb(details) {
+ browser.test.log(`onCompleted: ${JSON.stringify(details)}`);
+
+ if (details.tabId === tab?.id && details.frameId === 0) {
+ browser.webNavigation.onCompleted.removeListener(cb);
+ resolve();
+ }
+ });
+ });
+
+ browser.test.log(`Load a test page: ${file}`);
+ tab = await browser.tabs.create({ url: `${base}/${file}` });
+ await completed;
+
+ let [parents] = await browser.tabs.executeScript(tab.id, {
+ file: "cs.js"
+ });
+
+ let all = await browser.webNavigation.getAllFrames({ tabId: tab.id });
+ browser.test.log(`getAllFrames: ${JSON.stringify(all)}`);
+
+ browser.test.assertEq(all.length, count, "All frames accounted for.");
+
+ browser.test.assertEq(
+ Object.keys(parents).length,
+ count,
+ "All frames accounted for from content script."
+ );
+
+ for (let frame of all) {
+ browser.test.assertEq(
+ frame.parentFrameId,
+ parents[frame.frameId],
+ "Correct frame ancestor info."
+ );
+ }
+
+ await browser.tabs.remove(tab.id);
+ }
+
+ browser.tabs.create({ url: browser.runtime.getURL("closedPopup.html" )});
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+</script>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html
new file mode 100644
index 0000000000..de2a2571a9
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html
@@ -0,0 +1,101 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script private browsing ID</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ChromeTask.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+async function test_contentscript_incognito() {
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ content_scripts: [
+ {
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ },
+ ],
+ },
+
+ background() {
+ let windowId;
+
+ browser.test.onMessage.addListener(([msg, url]) => {
+ if (msg === "open-window") {
+ browser.windows.create({url, incognito: true}).then(window => {
+ windowId = window.id;
+ });
+ } else if (msg === "close-window") {
+ browser.windows.remove(windowId).then(() => {
+ browser.test.sendMessage("done");
+ });
+ }
+ });
+ },
+
+ files: {
+ "content_script.js": async () => {
+ const COOKIE = "foo=florgheralzps";
+ document.cookie = COOKIE;
+
+ let url = new URL("return_headers.sjs", location.href);
+
+ let responses = [
+ new Promise(resolve => {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", url);
+ xhr.onload = () => resolve(JSON.parse(xhr.responseText));
+ xhr.send();
+ }),
+
+ fetch(url, {credentials: "include"}).then(body => body.json()),
+ ];
+
+ try {
+ for (let response of await Promise.all(responses)) {
+ browser.test.assertEq(COOKIE, response.cookie, "Got expected cookie header");
+ }
+ browser.test.notifyPass("cookies");
+ } catch (e) {
+ browser.test.fail(`Error: ${e}`);
+ browser.test.notifyFail("cookies");
+ }
+ },
+ },
+ });
+
+ await extension.startup();
+
+ extension.sendMessage(["open-window", SimpleTest.getTestFileURL("file_sample.html")]);
+
+ await extension.awaitFinish("cookies");
+
+ extension.sendMessage(["close-window"]);
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+}
+
+add_task(async function() {
+ await test_contentscript_incognito();
+});
+
+add_task(async function() {
+ await SpecialPowers.pushPrefEnv({set: [
+ ["network.cookie.cookieBehavior", 3],
+ ]});
+ await test_contentscript_incognito();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html
new file mode 100644
index 0000000000..8ab4b1fb28
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html
@@ -0,0 +1,59 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_contentscript() {
+ function background() {
+ browser.test.onMessage.addListener(async url => {
+ let tab = await browser.tabs.create({url});
+
+ let executed = true;
+ try {
+ await browser.tabs.executeScript(tab.id, {code: "true;"});
+ } catch (e) {
+ executed = false;
+ }
+
+ await browser.tabs.remove([tab.id]);
+ browser.test.sendMessage("executed", executed);
+ });
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: ["<all_urls>"],
+ },
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ extension.sendMessage("https://example.com");
+ let result = await extension.awaitMessage("executed");
+ is(result, true, "Content script can be run in a page without mozAddonManager");
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webapi.testing", true]],
+ });
+
+ extension.sendMessage("https://example.com");
+ result = await extension.awaitMessage("executed");
+ is(result, false, "Content script cannot be run in a page with mozAddonManager");
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies.html
new file mode 100644
index 0000000000..cdec628975
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies.html
@@ -0,0 +1,367 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_cookies() {
+ await SpecialPowers.pushPrefEnv({set: [
+ ["dom.security.https_first_pbm", false],
+ ["dom.security.https_first", false],
+ ]});
+
+ async function background() {
+ function assertExpected(expected, cookie) {
+ for (let key of Object.keys(cookie)) {
+ browser.test.assertTrue(key in expected, `found property ${key}`);
+ browser.test.assertEq(expected[key], cookie[key], `property value for ${key} is correct`);
+ }
+ browser.test.assertEq(Object.keys(expected).length, Object.keys(cookie).length, "all expected properties found");
+ }
+
+ async function getDocumentCookie(tabId) {
+ let results = await browser.tabs.executeScript(tabId, {
+ code: "document.cookie",
+ });
+ browser.test.assertEq(1, results.length, "executeScript returns one result");
+ return results[0];
+ }
+
+ async function testIpCookie(ipAddress, setHostOnly) {
+ const IP_TEST_HOST = ipAddress;
+ const IP_TEST_URL = `http://${IP_TEST_HOST}/`;
+ const IP_THE_FUTURE = Date.now() + 5 * 60;
+ const IP_STORE_ID = "firefox-default";
+
+ let expectedCookie = {
+ name: "name1",
+ value: "value1",
+ domain: IP_TEST_HOST,
+ hostOnly: true,
+ path: "/",
+ secure: false,
+ httpOnly: false,
+ sameSite: "no_restriction",
+ session: false,
+ expirationDate: IP_THE_FUTURE,
+ storeId: IP_STORE_ID,
+ firstPartyDomain: "",
+ partitionKey: null,
+ };
+
+ await browser.browsingData.removeCookies({});
+ let ip_cookie = await browser.cookies.set({
+ url: IP_TEST_URL,
+ domain: setHostOnly ? ipAddress : undefined,
+ name: "name1",
+ value: "value1",
+ expirationDate: IP_THE_FUTURE,
+ });
+ assertExpected(expectedCookie, ip_cookie);
+
+ let ip_cookies = await browser.cookies.getAll({name: "name1"});
+ browser.test.assertEq(1, ip_cookies.length, "ip cookie can be added");
+ assertExpected(expectedCookie, ip_cookies[0]);
+
+ ip_cookies = await browser.cookies.getAll({domain: IP_TEST_HOST, name: "name1"});
+ browser.test.assertEq(1, ip_cookies.length, "can get ip cookie by host");
+ assertExpected(expectedCookie, ip_cookies[0]);
+
+ let ip_details = await browser.cookies.remove({url: IP_TEST_URL, name: "name1"});
+ assertExpected({url: IP_TEST_URL, name: "name1", storeId: IP_STORE_ID, firstPartyDomain: "", partitionKey: null}, ip_details);
+
+ ip_cookies = await browser.cookies.getAll({name: "name1"});
+ browser.test.assertEq(0, ip_cookies.length, "ip cookie can be removed");
+ }
+
+ async function openPrivateWindowAndTab(TEST_URL) {
+ // Add some random suffix to make sure that we select the right tab.
+ const PRIVATE_TEST_URL = TEST_URL + "?random" + Math.random();
+
+ let tabReadyPromise = new Promise((resolve) => {
+ browser.webNavigation.onDOMContentLoaded.addListener(function listener({tabId}) {
+ browser.webNavigation.onDOMContentLoaded.removeListener(listener);
+ resolve(tabId);
+ }, {
+ url: [{
+ urlPrefix: PRIVATE_TEST_URL,
+ }],
+ });
+ });
+ // This tab is opened for two purposes:
+ // 1. To allow tests to run content scripts in the context of a tab,
+ // for fetching the value of document.cookie.
+ // 2. TODO Bug 1309637 To work around cookies in incognito windows,
+ // based on the analysis in comment 8.
+ let {id: windowId} = await browser.windows.create({
+ incognito: true,
+ url: PRIVATE_TEST_URL,
+ });
+ let tabId = await tabReadyPromise;
+ return {windowId, tabId};
+ }
+
+ function changePort(href, port) {
+ let url = new URL(href);
+ url.port = port;
+ return url.href;
+ }
+
+ await testIpCookie("[2a03:4000:6:310e:216:3eff:fe53:99b]", false);
+ await testIpCookie("[2a03:4000:6:310e:216:3eff:fe53:99b]", true);
+ await testIpCookie("192.168.1.1", false);
+ await testIpCookie("192.168.1.1", true);
+
+ const TEST_URL = "http://example.org/";
+ const TEST_SECURE_URL = "https://example.org/";
+ const THE_FUTURE = Date.now() + 5 * 60;
+ const TEST_PATH = "set_path";
+ const TEST_URL_WITH_PATH = TEST_URL + TEST_PATH;
+ const TEST_COOKIE_PATH = `/${TEST_PATH}`;
+ const STORE_ID = "firefox-default";
+ const PRIVATE_STORE_ID = "firefox-private";
+
+ let expected = {
+ name: "name1",
+ value: "value1",
+ domain: "example.org",
+ hostOnly: true,
+ path: "/",
+ secure: false,
+ httpOnly: false,
+ sameSite: "no_restriction",
+ session: false,
+ expirationDate: THE_FUTURE,
+ storeId: STORE_ID,
+ firstPartyDomain: "",
+ partitionKey: null,
+ };
+
+ // Remove all cookies before starting the test.
+ await browser.browsingData.removeCookies({});
+
+ let cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1", expirationDate: THE_FUTURE});
+ assertExpected(expected, cookie);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1"});
+ assertExpected(expected, cookie);
+
+ let cookies = await browser.cookies.getAll({name: "name1"});
+ browser.test.assertEq(1, cookies.length, "one cookie found for matching name");
+ assertExpected(expected, cookies[0]);
+
+ cookies = await browser.cookies.getAll({domain: "example.org"});
+ browser.test.assertEq(1, cookies.length, "one cookie found for matching domain");
+ assertExpected(expected, cookies[0]);
+
+ cookies = await browser.cookies.getAll({domain: "example.net"});
+ browser.test.assertEq(0, cookies.length, "no cookies found for non-matching domain");
+
+ cookies = await browser.cookies.getAll({secure: false});
+ browser.test.assertEq(1, cookies.length, "one non-secure cookie found");
+ assertExpected(expected, cookies[0]);
+
+ cookies = await browser.cookies.getAll({secure: true});
+ browser.test.assertEq(0, cookies.length, "no secure cookies found");
+
+ cookies = await browser.cookies.getAll({storeId: STORE_ID});
+ browser.test.assertEq(1, cookies.length, "one cookie found for valid storeId");
+ assertExpected(expected, cookies[0]);
+
+ cookies = await browser.cookies.getAll({storeId: "invalid_id"});
+ browser.test.assertEq(0, cookies.length, "no cookies found for invalid storeId");
+
+ let details = await browser.cookies.remove({url: TEST_URL, name: "name1"});
+ assertExpected({url: TEST_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1"});
+ browser.test.assertEq(null, cookie, "removed cookie not found");
+
+ // Ports in cookie URLs should be ignored. Every API call uses a different port number for better coverage.
+ cookie = await browser.cookies.set({url: changePort(TEST_URL, 1234), name: "name1", value: "value1", expirationDate: THE_FUTURE});
+ assertExpected(expected, cookie);
+
+ cookie = await browser.cookies.get({url: changePort(TEST_URL, 65535), name: "name1"});
+ assertExpected(expected, cookie);
+
+ cookies = await browser.cookies.getAll({url: TEST_URL});
+ browser.test.assertEq(cookies.length, 1, "Found cookie using getAll without port");
+ assertExpected(expected, cookies[0]);
+
+ cookies = await browser.cookies.getAll({url: changePort(TEST_URL, 1)});
+ browser.test.assertEq(cookies.length, 1, "Found cookie using getAll with port");
+ assertExpected(expected, cookies[0]);
+
+ // .remove should return the URL of the API call, so the port is included in the return value.
+ const TEST_URL_TO_REMOVE = changePort(TEST_URL, 1023);
+ details = await browser.cookies.remove({url: TEST_URL_TO_REMOVE, name: "name1"});
+ assertExpected({url: TEST_URL_TO_REMOVE, name: "name1", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1"});
+ browser.test.assertEq(null, cookie, "removed cookie not found");
+
+ let stores = await browser.cookies.getAllCookieStores();
+ browser.test.assertEq(1, stores.length, "expected number of stores returned");
+ browser.test.assertEq(STORE_ID, stores[0].id, "expected store id returned");
+ browser.test.assertEq(1, stores[0].tabIds.length, "one tabId returned for store");
+ browser.test.assertEq("number", typeof stores[0].tabIds[0], "tabId is a number");
+
+ // TODO bug 1372178: Opening private windows/tabs is not supported on Android
+ if (browser.windows) {
+ let {windowId} = await openPrivateWindowAndTab(TEST_URL);
+ let stores = await browser.cookies.getAllCookieStores();
+
+ browser.test.assertEq(2, stores.length, "expected number of stores returned");
+ browser.test.assertEq(STORE_ID, stores[0].id, "expected store id returned");
+ browser.test.assertEq(1, stores[0].tabIds.length, "one tab returned for store");
+ browser.test.assertEq(PRIVATE_STORE_ID, stores[1].id, "expected private store id returned");
+ browser.test.assertEq(1, stores[0].tabIds.length, "one tab returned for private store");
+
+ await browser.windows.remove(windowId);
+ }
+
+ cookie = await browser.cookies.set({url: TEST_URL, name: "name2", domain: ".example.org", expirationDate: THE_FUTURE});
+ browser.test.assertEq(false, cookie.hostOnly, "cookie is not a hostOnly cookie");
+
+ details = await browser.cookies.remove({url: TEST_URL, name: "name2"});
+ assertExpected({url: TEST_URL, name: "name2", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details);
+
+ // Create a session cookie.
+ cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1"});
+ browser.test.assertEq(true, cookie.session, "session cookie set");
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1"});
+ browser.test.assertEq(true, cookie.session, "got session cookie");
+
+ cookies = await browser.cookies.getAll({session: true});
+ browser.test.assertEq(1, cookies.length, "one session cookie found");
+ browser.test.assertEq(true, cookies[0].session, "found session cookie");
+
+ cookies = await browser.cookies.getAll({session: false});
+ browser.test.assertEq(0, cookies.length, "no non-session cookies found");
+
+ details = await browser.cookies.remove({url: TEST_URL, name: "name1"});
+ assertExpected({url: TEST_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1"});
+ browser.test.assertEq(null, cookie, "removed cookie not found");
+
+ cookie = await browser.cookies.set({url: TEST_SECURE_URL, name: "name1", value: "value1", secure: true});
+ browser.test.assertEq(true, cookie.secure, "secure cookie set");
+
+ cookie = await browser.cookies.get({url: TEST_SECURE_URL, name: "name1"});
+ browser.test.assertEq(true, cookie.session, "got secure cookie");
+
+ cookies = await browser.cookies.getAll({secure: true});
+ browser.test.assertEq(1, cookies.length, "one secure cookie found");
+ browser.test.assertEq(true, cookies[0].secure, "found secure cookie");
+
+ cookies = await browser.cookies.getAll({secure: false});
+ browser.test.assertEq(0, cookies.length, "no non-secure cookies found");
+
+ details = await browser.cookies.remove({url: TEST_SECURE_URL, name: "name1"});
+ assertExpected({url: TEST_SECURE_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details);
+
+ cookie = await browser.cookies.get({url: TEST_SECURE_URL, name: "name1"});
+ browser.test.assertEq(null, cookie, "removed cookie not found");
+
+ cookie = await browser.cookies.set({url: TEST_URL_WITH_PATH, path: TEST_COOKIE_PATH, name: "name1", value: "value1", expirationDate: THE_FUTURE});
+ browser.test.assertEq(TEST_COOKIE_PATH, cookie.path, "created cookie with path");
+
+ cookie = await browser.cookies.get({url: TEST_URL_WITH_PATH, name: "name1"});
+ browser.test.assertEq(TEST_COOKIE_PATH, cookie.path, "got cookie with path");
+
+ cookies = await browser.cookies.getAll({path: TEST_COOKIE_PATH});
+ browser.test.assertEq(1, cookies.length, "one cookie with path found");
+ browser.test.assertEq(TEST_COOKIE_PATH, cookies[0].path, "found cookie with path");
+
+ cookie = await browser.cookies.get({url: TEST_URL + "invalid_path", name: "name1"});
+ browser.test.assertEq(null, cookie, "get with invalid path returns null");
+
+ cookies = await browser.cookies.getAll({path: "/invalid_path"});
+ browser.test.assertEq(0, cookies.length, "getAll with invalid path returns 0 cookies");
+
+ details = await browser.cookies.remove({url: TEST_URL_WITH_PATH, name: "name1"});
+ assertExpected({url: TEST_URL_WITH_PATH, name: "name1", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details);
+
+ cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1", httpOnly: true});
+ browser.test.assertEq(true, cookie.httpOnly, "httpOnly cookie set");
+
+ cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1", httpOnly: false});
+ browser.test.assertEq(false, cookie.httpOnly, "non-httpOnly cookie set");
+
+ details = await browser.cookies.remove({url: TEST_URL, name: "name1"});
+ assertExpected({url: TEST_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details);
+
+ cookie = await browser.cookies.set({url: TEST_URL});
+ browser.test.assertEq("", cookie.name, "default name set");
+ browser.test.assertEq("", cookie.value, "default value set");
+ browser.test.assertEq(true, cookie.session, "no expiry date created session cookie");
+
+ // TODO bug 1372178: Opening private windows/tabs is not supported on Android
+ if (browser.windows) {
+ let {tabId, windowId} = await openPrivateWindowAndTab(TEST_URL);
+
+ browser.test.assertEq("", await getDocumentCookie(tabId), "initially no cookie");
+
+ let cookie = await browser.cookies.set({url: TEST_URL, name: "store", value: "private", expirationDate: THE_FUTURE, storeId: PRIVATE_STORE_ID});
+ browser.test.assertEq("private", cookie.value, "set the private cookie");
+
+ cookie = await browser.cookies.set({url: TEST_URL, name: "store", value: "default", expirationDate: THE_FUTURE, storeId: STORE_ID});
+ browser.test.assertEq("default", cookie.value, "set the default cookie");
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID});
+ browser.test.assertEq("private", cookie.value, "get the private cookie");
+ browser.test.assertEq(PRIVATE_STORE_ID, cookie.storeId, "get the private cookie storeId");
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: STORE_ID});
+ browser.test.assertEq("default", cookie.value, "get the default cookie");
+ browser.test.assertEq(STORE_ID, cookie.storeId, "get the default cookie storeId");
+
+ browser.test.assertEq("store=private", await getDocumentCookie(tabId), "private document.cookie should be set");
+
+ let details = await browser.cookies.remove({url: TEST_URL, name: "store", storeId: STORE_ID});
+ assertExpected({url: TEST_URL, name: "store", storeId: STORE_ID, firstPartyDomain: "", partitionKey: null}, details);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: STORE_ID});
+ browser.test.assertEq(null, cookie, "deleted the default cookie");
+
+ details = await browser.cookies.remove({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID});
+ assertExpected({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID, firstPartyDomain: "", partitionKey: null}, details);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID});
+ browser.test.assertEq(null, cookie, "deleted the private cookie");
+
+ browser.test.assertEq("", await getDocumentCookie(tabId), "private document.cookie should be removed");
+
+ await browser.windows.remove(windowId);
+ }
+
+ browser.test.notifyPass("cookies");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ background,
+ manifest: {
+ permissions: ["cookies", "*://example.org/", "*://[2a03:4000:6:310e:216:3eff:fe53:99b]/", "*://192.168.1.1/", "webNavigation", "browsingData"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("cookies");
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html
new file mode 100644
index 0000000000..d4bbd61177
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html
@@ -0,0 +1,98 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ // make sure userContext is enabled.
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["privacy.userContext.enabled", true],
+ ]});
+});
+
+add_task(async function test_cookie_containers() {
+ async function background() {
+ // Sometimes there is a cookie without name/value when running tests.
+ let cookiesAtStart = await browser.cookies.getAll({storeId: "firefox-default"});
+
+ function assertExpected(expected, cookie) {
+ for (let key of Object.keys(cookie)) {
+ browser.test.assertTrue(key in expected, `found property ${key}`);
+ browser.test.assertEq(expected[key], cookie[key], `property value for ${key} is correct`);
+ }
+ browser.test.assertEq(Object.keys(expected).length, Object.keys(cookie).length, "all expected properties found");
+ }
+
+ const TEST_URL = "http://example.org/";
+ const THE_FUTURE = Date.now() + 5 * 60;
+
+ let expected = {
+ name: "name1",
+ value: "value1",
+ domain: "example.org",
+ hostOnly: true,
+ path: "/",
+ secure: false,
+ httpOnly: false,
+ sameSite: "no_restriction",
+ session: false,
+ expirationDate: THE_FUTURE,
+ storeId: "firefox-container-1",
+ firstPartyDomain: "",
+ partitionKey: null,
+ };
+
+ let cookie = await browser.cookies.set({
+ url: TEST_URL, name: "name1", value: "value1",
+ expirationDate: THE_FUTURE, storeId: "firefox-container-1",
+ });
+ browser.test.assertEq("firefox-container-1", cookie.storeId, "the cookie has the correct storeId");
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1"});
+ browser.test.assertEq(null, cookie, "get() without storeId returns null");
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1", storeId: "firefox-container-1"});
+ assertExpected(expected, cookie);
+
+ let cookies = await browser.cookies.getAll({storeId: "firefox-default"});
+ browser.test.assertEq(0, cookiesAtStart.length - cookies.length, "getAll() with default storeId hasn't added cookies");
+
+ cookies = await browser.cookies.getAll({storeId: "firefox-container-1"});
+ browser.test.assertEq(1, cookies.length, "one cookie found for matching domain");
+ assertExpected(expected, cookies[0]);
+
+ let details = await browser.cookies.remove({url: TEST_URL, name: "name1", storeId: "firefox-container-1"});
+ assertExpected({url: TEST_URL, name: "name1", storeId: "firefox-container-1", firstPartyDomain: "", partitionKey: null}, details);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1", storeId: "firefox-container-1"});
+ browser.test.assertEq(null, cookie, "removed cookie not found");
+
+ browser.test.notifyPass("cookies");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["cookies", "*://example.org/"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("cookies");
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html
new file mode 100644
index 0000000000..fa118f5271
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html
@@ -0,0 +1,72 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension cookies test</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_cookies_expiry() {
+ function background() {
+ let expectedEvents = [];
+
+ browser.cookies.onChanged.addListener(event => {
+ expectedEvents.push(`${event.removed}:${event.cause}`);
+ if (expectedEvents.length === 1) {
+ browser.test.assertEq("true:expired", expectedEvents[0], "expired cookie removed");
+ browser.test.assertEq("first", event.cookie.name, "expired cookie has the expected name");
+ browser.test.assertEq("one", event.cookie.value, "expired cookie has the expected value");
+ } else {
+ browser.test.assertEq("false:explicit", expectedEvents[1], "new cookie added");
+ browser.test.assertEq("first", event.cookie.name, "new cookie has the expected name");
+ browser.test.assertEq("one-again", event.cookie.value, "new cookie has the expected value");
+ browser.test.notifyPass("cookie-expiry");
+ }
+ });
+
+ setTimeout(() => {
+ browser.test.sendMessage("change-cookies");
+ }, 1000);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ "permissions": ["http://example.com/", "cookies"],
+ },
+ background,
+ });
+
+ let chromeScript = loadChromeScript(() => {
+ const {sendAsyncMessage} = this;
+ Services.cookies.add(".example.com", "/", "first", "one", false, false, false, Date.now() / 1000 + 1, {}, Ci.nsICookie.SAMESITE_NONE, Ci.nsICookie.SCHEME_HTTP);
+ sendAsyncMessage("done");
+ });
+ await chromeScript.promiseOneMessage("done");
+ chromeScript.destroy();
+
+ await extension.startup();
+ await extension.awaitMessage("change-cookies");
+
+ chromeScript = loadChromeScript(() => {
+ const {sendAsyncMessage} = this;
+ Services.cookies.add(".example.com", "/", "first", "one-again", false, false, false, Date.now() / 1000 + 10, {}, Ci.nsICookie.SAMESITE_NONE, Ci.nsICookie.SCHEME_HTTP);
+ sendAsyncMessage("done");
+ });
+ await chromeScript.promiseOneMessage("done");
+ chromeScript.destroy();
+
+ await extension.awaitFinish("cookie-expiry");
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html
new file mode 100644
index 0000000000..7e33f4731d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html
@@ -0,0 +1,316 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+<script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+<script src="head.js"></script>
+<script>
+"use strict";
+
+async function background() {
+ const url = "http://ext-cookie-first-party.mochi.test/";
+ const firstPartyDomain = "ext-cookie-first-party.mochi.test";
+ // A first party domain with invalid characters for the file system, which just happens to be a IPv6 address.
+ const firstPartyDomainInvalidChars = "[2606:4700:4700::1111]";
+ const expectedError = "First-Party Isolation is enabled, but the required 'firstPartyDomain' attribute was not set.";
+
+ const assertExpectedCookies = (expected, cookies, message) => {
+ let matches = (cookie, expected) => {
+ if (!cookie || !expected) {
+ return cookie === expected; // true if both are null.
+ }
+ for (let key of Object.keys(expected)) {
+ if (cookie[key] !== expected[key]) {
+ return false;
+ }
+ }
+ return true;
+ };
+ browser.test.assertEq(expected.length, cookies.length, `Got expected number of cookies - ${message}`);
+ if (cookies.length !== expected.length) {
+ return;
+ }
+ for (let expect of expected) {
+ let foundCookies = cookies.filter(cookie => matches(cookie, expect));
+ browser.test.assertEq(1, foundCookies.length,
+ `Expected cookie ${JSON.stringify(expect)} found - ${message}`);
+ }
+ };
+
+ // Test when FPI is disabled.
+ const test_fpi_disabled = async () => {
+ let cookie, cookies;
+
+ // set
+ cookie = await browser.cookies.set({url, name: "foo1", value: "bar1"});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], [cookie], "set: FPI off, w/ empty firstPartyDomain, non-FP cookie");
+ cookie = await browser.cookies.set({url, name: "foo2", value: "bar2", firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ ], [cookie], "set: FPI off, w/ firstPartyDomain, FP cookie");
+
+ // get
+ // When FPI is disabled, missing key/null/undefined is equivalent to "".
+ cookie = await browser.cookies.get({url, name: "foo1"});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], [cookie], "get: FPI off, w/o firstPartyDomain, non-FP cookie");
+ cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: ""});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], [cookie], "get: FPI off, w/ empty firstPartyDomain, non-FP cookie");
+ cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: null});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], [cookie], "get: FPI off, w/ null firstPartyDomain, non-FP cookie");
+ cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: undefined});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], [cookie], "get: FPI off, w/ undefined firstPartyDomain, non-FP cookie");
+
+ cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ ], [cookie], "get: FPI off, w/ firstPartyDomain, FP cookie");
+ // There is no match for non-FP cookies with name "foo2".
+ cookie = await browser.cookies.get({url, name: "foo2"});
+ assertExpectedCookies([null], [cookie], "get: FPI off, w/o firstPartyDomain, no cookie");
+ cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain: ""});
+ assertExpectedCookies([null], [cookie], "get: FPI off, w/ empty firstPartyDomain, no cookie");
+ cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain: null});
+ assertExpectedCookies([null], [cookie], "get: FPI off, w/ null firstPartyDomain, no cookie");
+ cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain: undefined});
+ assertExpectedCookies([null], [cookie], "get: FPI off, w/ undefined firstPartyDomain, no cookie");
+
+ // getAll
+ for (let extra of [{}, {url}, {domain: firstPartyDomain}]) {
+ const prefix = `getAll(${JSON.stringify(extra)})`;
+ cookies = await browser.cookies.getAll({...extra});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], cookies, `${prefix}: FPI off, w/o firstPartyDomain, non-FP cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain: ""});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], cookies, `${prefix}: FPI off, w/ empty firstPartyDomain, non-FP cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain: null});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ ], cookies, `${prefix}: FPI off, w/ null firstPartyDomain, all cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain: undefined});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ ], cookies, `${prefix}: FPI off, w/ undefined firstPartyDomain, all cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ ], cookies, `${prefix}: FPI off, w/ firstPartyDomain, FP cookies`);
+ }
+
+ // remove
+ cookie = await browser.cookies.remove({url, name: "foo1"});
+ assertExpectedCookies([
+ {url, name: "foo1", firstPartyDomain: ""},
+ ], [cookie], "remove: FPI off, w/ empty firstPartyDomain, non-FP cookie");
+ cookie = await browser.cookies.remove({url, name: "foo2", firstPartyDomain});
+ assertExpectedCookies([
+ {url, name: "foo2", firstPartyDomain},
+ ], [cookie], "remove: FPI off, w/ firstPartyDomain, FP cookie");
+
+ // Test if FP cookies set when FPI off can be accessed when FPI on.
+ await browser.cookies.set({url, name: "foo1", value: "bar1"});
+ await browser.cookies.set({url, name: "foo2", value: "bar2", firstPartyDomain});
+
+ browser.test.sendMessage("test_fpi_disabled");
+ };
+
+ // Test when FPI is enabled.
+ const test_fpi_enabled = async () => {
+ let cookie, cookies;
+
+ // set
+ await browser.test.assertRejects(
+ browser.cookies.set({url, name: "foo3", value: "bar3"}),
+ expectedError,
+ "set: FPI on, w/o firstPartyDomain, rejection");
+ cookie = await browser.cookies.set({url, name: "foo4", value: "bar4", firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo4", value: "bar4", firstPartyDomain},
+ ], [cookie], "set: FPI on, w/ firstPartyDomain, FP cookie");
+
+ // get
+ await browser.test.assertRejects(
+ browser.cookies.get({url, name: "foo3"}),
+ expectedError,
+ "get: FPI on, w/o firstPartyDomain, rejection");
+ await browser.test.assertRejects(
+ browser.cookies.get({url, name: "foo3", firstPartyDomain: null}),
+ expectedError,
+ "get: FPI on, w/ null firstPartyDomain, rejection");
+ await browser.test.assertRejects(
+ browser.cookies.get({url, name: "foo3", firstPartyDomain: undefined}),
+ expectedError,
+ "get: FPI on, w/ undefined firstPartyDomain, rejection");
+ cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: ""});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], [cookie], "get: FPI on, w/ empty firstPartyDomain, non-FP cookie");
+ cookie = await browser.cookies.get({url, name: "foo4", firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo4", value: "bar4", firstPartyDomain},
+ ], [cookie], "get: FPI on, w/ firstPartyDomain, FP cookie");
+ cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ ], [cookie], "get: FPI on, w/ firstPartyDomain, FP cookie (set when FPI off)");
+
+ // getAll
+ for (let extra of [{}, {url}, {domain: firstPartyDomain}]) {
+ const prefix = `getAll(${JSON.stringify(extra)})`;
+ await browser.test.assertRejects(
+ browser.cookies.getAll({...extra}),
+ expectedError,
+ `${prefix}: FPI on, w/o firstPartyDomain, rejection`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain: ""});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], cookies, `${prefix}: FPI on, w/ empty firstPartyDomain, non-FP cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain: null});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ {name: "foo4", value: "bar4", firstPartyDomain},
+ ], cookies, `${prefix}: FPI on, w/ null firstPartyDomain, all cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain: undefined});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ {name: "foo4", value: "bar4", firstPartyDomain},
+ ], cookies, `${prefix}: FPI on, w/ undefined firstPartyDomain, all cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ {name: "foo4", value: "bar4", firstPartyDomain},
+ ], cookies, `${prefix}: FPI on, w/ firstPartyDomain, FP cookies`);
+ }
+
+ // remove
+ await browser.test.assertRejects(
+ browser.cookies.remove({url, name: "foo3"}),
+ expectedError,
+ "remove: FPI on, w/o firstPartyDomain, rejection");
+ cookie = await browser.cookies.remove({url, name: "foo4", firstPartyDomain});
+ assertExpectedCookies([
+ {url, name: "foo4", firstPartyDomain},
+ ], [cookie], "remove: FPI on, w/ firstPartyDomain, FP cookie");
+ cookie = await browser.cookies.remove({url, name: "foo2", firstPartyDomain});
+ assertExpectedCookies([
+ {url, name: "foo2", firstPartyDomain},
+ ], [cookie], "remove: FPI on, w/ firstPartyDomain, FP cookie (set when FPI off)");
+
+ // Test if FP cookies set when FPI on can be accessed when FPI off.
+ await browser.cookies.set({url, name: "foo4", value: "bar4", firstPartyDomain});
+
+ browser.test.sendMessage("test_fpi_enabled");
+ };
+
+ // Test FPI with a first party domain with invalid characters for
+ // the file system.
+ const test_fpi_with_invalid_characters = async () => {
+ let cookie;
+
+ // Test setting a cookie with a first party domain with invalid characters
+ // for the file system.
+ cookie = await browser.cookies.set({url, name: "foo5", value: "bar5",
+ firstPartyDomain: firstPartyDomainInvalidChars});
+ assertExpectedCookies([
+ {name: "foo5", value: "bar5", firstPartyDomain: firstPartyDomainInvalidChars},
+ ], [cookie], "set: FPI on, w/ firstPartyDomain with invalid characters, FP cookie");
+
+ // Test getting a cookie with a first party domain with invalid characters
+ // for the file system.
+ cookie = await browser.cookies.get({url, name: "foo5",
+ firstPartyDomain: firstPartyDomainInvalidChars});
+ assertExpectedCookies([
+ {name: "foo5", value: "bar5", firstPartyDomain: firstPartyDomainInvalidChars},
+ ], [cookie], "get: FPI on, w/ firstPartyDomain with invalid characters, FP cookie");
+
+ // Test removing a cookie with a first party domain with invalid characters
+ // for the file system.
+ cookie = await browser.cookies.remove({url, name: "foo5",
+ firstPartyDomain: firstPartyDomainInvalidChars});
+ assertExpectedCookies([
+ {url, name: "foo5", firstPartyDomain: firstPartyDomainInvalidChars},
+ ], [cookie], "remove: FPI on, w/ firstPartyDomain with invalid characters, FP cookie");
+
+ browser.test.sendMessage("test_fpi_with_invalid_characters");
+ };
+
+ // Test when FPI is disabled again, accessing FP cookies set when FPI is enabled.
+ const test_fpd_cookies_on_fpi_disabled = async () => {
+ let cookie, cookies;
+ cookie = await browser.cookies.get({url, name: "foo4", firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo4", value: "bar4", firstPartyDomain},
+ ], [cookie], "get: FPI off, w/ firstPartyDomain, FP cookie (set when FPI on)");
+ cookie = await browser.cookies.remove({url, name: "foo4", firstPartyDomain});
+ assertExpectedCookies([
+ {url, name: "foo4", firstPartyDomain},
+ ], [cookie], "remove: FPI off, w/ firstPartyDomain, FP cookie (set when FPI on)");
+
+ // Clean up.
+ await browser.cookies.remove({url, name: "foo1"});
+
+ cookies = await browser.cookies.getAll({firstPartyDomain: null});
+ assertExpectedCookies([], cookies, "Test is finishing, all cookies removed");
+
+ browser.test.sendMessage("test_fpd_cookies_on_fpi_disabled");
+ };
+
+ browser.test.onMessage.addListener((message) => {
+ switch (message) {
+ case "test_fpi_disabled": return test_fpi_disabled();
+ case "test_fpi_enabled": return test_fpi_enabled();
+ case "test_fpi_with_invalid_characters": return test_fpi_with_invalid_characters();
+ case "test_fpd_cookies_on_fpi_disabled": return test_fpd_cookies_on_fpi_disabled();
+ default: return browser.test.notifyFail("unknown-message");
+ }
+ });
+}
+
+function enableFirstPartyIsolation() {
+ return SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.firstparty.isolate", true],
+ ],
+ });
+}
+
+function disableFirstPartyIsolation() {
+ return SpecialPowers.popPrefEnv();
+}
+
+add_task(async () => {
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["cookies", "*://ext-cookie-first-party.mochi.test/"],
+ },
+ });
+ await extension.startup();
+ extension.sendMessage("test_fpi_disabled");
+ await extension.awaitMessage("test_fpi_disabled");
+ await enableFirstPartyIsolation();
+ extension.sendMessage("test_fpi_enabled");
+ await extension.awaitMessage("test_fpi_enabled");
+ extension.sendMessage("test_fpi_with_invalid_characters");
+ await extension.awaitMessage("test_fpi_with_invalid_characters");
+ await disableFirstPartyIsolation();
+ extension.sendMessage("test_fpd_cookies_on_fpi_disabled");
+ await extension.awaitMessage("test_fpd_cookies_on_fpi_disabled");
+ await extension.unload();
+});
+</script>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html
new file mode 100644
index 0000000000..b33ceecf06
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html
@@ -0,0 +1,107 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_cookies_incognito_not_allowed() {
+ let privateExtension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ async background() {
+ let window = await browser.windows.create({incognito: true});
+ browser.test.onMessage.addListener(async () => {
+ await browser.windows.remove(window.id);
+ browser.test.sendMessage("done");
+ });
+ browser.test.sendMessage("ready");
+ },
+ manifest: {
+ permissions: ["cookies", "*://example.org/"],
+ },
+ });
+ await privateExtension.startup();
+ await privateExtension.awaitMessage("ready");
+
+ async function background() {
+ const storeId = "firefox-private";
+ const url = "http://example.org/";
+
+ // Getting the wrong storeId will fail, otherwise we should finish the test fine.
+ browser.cookies.onChanged.addListener(changeInfo => {
+ let {cookie} = changeInfo;
+ browser.test.assertTrue(cookie.storeId != storeId, "cookie store is correct");
+ });
+
+ browser.test.onMessage.addListener(async () => {
+ let stores = await browser.cookies.getAllCookieStores();
+ let store = stores.find(s => s.incognito);
+ browser.test.assertTrue(!store, "incognito cookie store should not be available");
+ browser.test.notifyPass("cookies");
+ });
+
+ await browser.test.assertRejects(
+ browser.cookies.set({url, name: "test", storeId}),
+ /Extension disallowed access/,
+ "API should reject setting cookie");
+ await browser.test.assertRejects(
+ browser.cookies.get({url, name: "test", storeId}),
+ /Extension disallowed access/,
+ "API should reject getting cookie");
+ await browser.test.assertRejects(
+ browser.cookies.getAll({url, storeId}),
+ /Extension disallowed access/,
+ "API should reject getting cookie");
+ await browser.test.assertRejects(
+ browser.cookies.remove({url, name: "test", storeId}),
+ /Extension disallowed access/,
+ "API should reject getting cookie");
+ await browser.test.assertRejects(
+ browser.cookies.getAll({url, storeId}),
+ /Extension disallowed access/,
+ "API should reject getting cookie");
+
+ browser.test.sendMessage("set-cookies");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["cookies", "*://example.org/"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("set-cookies");
+
+ let chromeScript = SpecialPowers.loadChromeScript(() => {
+ /* eslint-env mozilla/chrome-script */
+ Services.cookies.add("example.org", "/", "public", `foo${Math.random()}`,
+ false, false, false, Number.MAX_SAFE_INTEGER, {},
+ Ci.nsICookie.SAMESITE_NONE);
+ Services.cookies.add("example.org", "/", "private", `foo${Math.random()}`,
+ false, false, false, Number.MAX_SAFE_INTEGER, {privateBrowsingId: 1},
+ Ci.nsICookie.SAMESITE_NONE);
+ });
+ extension.sendMessage("test-cookie-store");
+ await extension.awaitFinish("cookies");
+
+ await extension.unload();
+ privateExtension.sendMessage("close");
+ await privateExtension.awaitMessage("done");
+ await privateExtension.unload();
+ chromeScript.destroy();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html
new file mode 100644
index 0000000000..0bd2852075
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html
@@ -0,0 +1,115 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <script type="text/javascript" src="head_cookies.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function init() {
+ // We need to trigger a cookie eviction in order to test our batch delete
+ // observer.
+
+ // Set quotaPerHost to maxPerHost - 1, so there is only one cookie
+ // will be evicted everytime.
+ SpecialPowers.setIntPref("network.cookie.quotaPerHost", 2);
+ SpecialPowers.setIntPref("network.cookie.maxPerHost", 3);
+ SimpleTest.registerCleanupFunction(() => {
+ SpecialPowers.clearUserPref("network.cookie.quotaPerHost");
+ SpecialPowers.clearUserPref("network.cookie.maxPerHost");
+ });
+});
+
+add_task(async function test_bad_cookie_permissions() {
+ info("Test non-matching, non-secure domain with non-secure cookie");
+ await testCookies({
+ permissions: ["http://example.com/", "cookies"],
+ url: "http://example.net/",
+ domain: "example.net",
+ secure: false,
+ shouldPass: false,
+ shouldWrite: false,
+ });
+
+ info("Test non-matching, secure domain with non-secure cookie");
+ await testCookies({
+ permissions: ["https://example.com/", "cookies"],
+ url: "https://example.net/",
+ domain: "example.net",
+ secure: false,
+ shouldPass: false,
+ shouldWrite: false,
+ });
+
+ info("Test non-matching, secure domain with secure cookie");
+ await testCookies({
+ permissions: ["https://example.com/", "cookies"],
+ url: "https://example.net/",
+ domain: "example.net",
+ secure: false,
+ shouldPass: false,
+ shouldWrite: false,
+ });
+
+ info("Test matching subdomain with superdomain privileges, secure cookie (http)");
+ await testCookies({
+ permissions: ["http://foo.bar.example.com/", "cookies"],
+ url: "http://foo.bar.example.com/",
+ domain: ".example.com",
+ secure: true,
+ shouldPass: false,
+ shouldWrite: true,
+ });
+
+ info("Test matching, non-secure domain with secure cookie");
+ await testCookies({
+ permissions: ["http://example.com/", "cookies"],
+ url: "http://example.com/",
+ domain: "example.com",
+ secure: true,
+ shouldPass: false,
+ shouldWrite: true,
+ });
+
+ info("Test matching, non-secure host, secure URL");
+ await testCookies({
+ permissions: ["http://example.com/", "cookies"],
+ url: "https://example.com/",
+ domain: "example.com",
+ secure: true,
+ shouldPass: false,
+ shouldWrite: false,
+ });
+
+ info("Test non-matching domain");
+ await testCookies({
+ permissions: ["http://example.com/", "cookies"],
+ url: "http://example.com/",
+ domain: "example.net",
+ secure: false,
+ shouldPass: false,
+ shouldWrite: false,
+ });
+
+ info("Test invalid scheme");
+ await testCookies({
+ permissions: ["ftp://example.com/", "cookies"],
+ url: "ftp://example.com/",
+ domain: "example.com",
+ secure: false,
+ shouldPass: false,
+ shouldWrite: false,
+ });
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html
new file mode 100644
index 0000000000..bd76f2b9c0
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html
@@ -0,0 +1,89 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <script type="text/javascript" src="head_cookies.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function init() {
+ // We need to trigger a cookie eviction in order to test our batch delete
+ // observer.
+
+ // Set quotaPerHost to maxPerHost - 1, so there is only one cookie
+ // will be evicted everytime.
+ SpecialPowers.setIntPref("network.cookie.quotaPerHost", 2);
+ SpecialPowers.setIntPref("network.cookie.maxPerHost", 3);
+ SimpleTest.registerCleanupFunction(() => {
+ SpecialPowers.clearUserPref("network.cookie.quotaPerHost");
+ SpecialPowers.clearUserPref("network.cookie.maxPerHost");
+ });
+});
+
+add_task(async function test_good_cookie_permissions() {
+ info("Test matching, non-secure domain with non-secure cookie");
+ await testCookies({
+ permissions: ["http://example.com/", "cookies"],
+ url: "http://example.com/",
+ domain: "example.com",
+ secure: false,
+ shouldPass: true,
+ });
+
+ info("Test matching, secure domain with non-secure cookie");
+ await testCookies({
+ permissions: ["https://example.com/", "cookies"],
+ url: "https://example.com/",
+ domain: "example.com",
+ secure: false,
+ shouldPass: true,
+ });
+
+ info("Test matching, secure domain with secure cookie");
+ await testCookies({
+ permissions: ["https://example.com/", "cookies"],
+ url: "https://example.com/",
+ domain: "example.com",
+ secure: true,
+ shouldPass: true,
+ });
+
+ info("Test matching subdomain with superdomain privileges, secure cookie (https)");
+ await testCookies({
+ permissions: ["https://foo.bar.example.com/", "cookies"],
+ url: "https://foo.bar.example.com/",
+ domain: ".example.com",
+ secure: true,
+ shouldPass: true,
+ });
+
+ info("Test matching subdomain with superdomain privileges, non-secure cookie (https)");
+ await testCookies({
+ permissions: ["https://foo.bar.example.com/", "cookies"],
+ url: "https://foo.bar.example.com/",
+ domain: ".example.com",
+ secure: false,
+ shouldPass: true,
+ });
+
+ info("Test matching subdomain with superdomain privileges, non-secure cookie (http)");
+ await testCookies({
+ permissions: ["http://foo.bar.example.com/", "cookies"],
+ url: "http://foo.bar.example.com/",
+ domain: ".example.com",
+ secure: false,
+ shouldPass: true,
+ });
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_dnr_other_extensions.html b/toolkit/components/extensions/test/mochitest/test_ext_dnr_other_extensions.html
new file mode 100644
index 0000000000..d3074b3dec
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_dnr_other_extensions.html
@@ -0,0 +1,113 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>DNR and tabs.create from other extension</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<script>
+"use strict";
+
+
+// While most DNR tests are xpcshell tests, this one is a mochitest because the
+// tabs.create API does not work in a xpcshell test.
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.manifestV3.enabled", true],
+ ["extensions.dnr.enabled", true],
+ ],
+ });
+});
+
+
+add_task(async function tabs_create_can_be_redirected_by_other_dnr_extension() {
+ let dnrExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["declarativeNetRequestWithHostAccess"],
+ // redirect action requires host permissions:
+ host_permissions: ["*://example.com/*"],
+ },
+ async background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: {
+ resourceTypes: ["main_frame"],
+ urlFilter: "?dnr_redir_me_pls",
+ },
+ action: {
+ type: "redirect",
+ redirect: {
+ transform: {
+ query: "?dnr_redir_target"
+ },
+ },
+ },
+ },
+ ],
+ });
+ browser.test.sendMessage("dnr_registered");
+ },
+ });
+ await dnrExtension.startup();
+ await dnrExtension.awaitMessage("dnr_registered");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webNavigation"],
+ },
+ async background() {
+ async function createTabAndGetFinalUrl(url) {
+ let navigationDonePromise = new Promise(resolve => {
+ browser.webNavigation.onDOMContentLoaded.addListener(
+ function listener(details) {
+ browser.webNavigation.onDOMContentLoaded.removeListener(listener);
+ resolve(details);
+ },
+ // All input URLs and redirection targets match this URL filter:
+ { url: [{ queryPrefix: "dnr_redir_" }] }
+ );
+ });
+ const tab = await browser.tabs.create({ url });
+ browser.test.log(`Waiting for navigation done, starting from ${url}`);
+ const result = await navigationDonePromise;
+ browser.test.assertEq(
+ tab.id,
+ result.tabId,
+ `Observed load completion for navigation tab with initial URL ${url}`
+ );
+ await browser.tabs.remove(tab.id);
+ return result.url;
+ }
+
+ browser.test.assertEq(
+ "https://example.com/?dnr_redir_target",
+ await createTabAndGetFinalUrl("https://example.com/?dnr_redir_me_pls"),
+ "DNR rule from other extension should have redirected the navigation"
+ );
+
+ browser.test.assertEq(
+ "https://example.org/?dnr_redir_me_pls",
+ await createTabAndGetFinalUrl("https://example.org/?dnr_redir_me_pls"),
+ "DNR redirect ignored for URLs without host permission"
+ );
+ browser.test.sendMessage("done");
+ }
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+
+ await dnrExtension.unload();
+ await extension.unload();
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html b/toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html
new file mode 100644
index 0000000000..0278a8ccc8
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html
@@ -0,0 +1,137 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>DNR with tabIds condition</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<script>
+"use strict";
+
+// While most DNR tests are xpcshell tests, this one is a mochitest because it
+// is not possible to create a tab and get a tabId in a xpcshell test.
+
+// toolkit/components/extensions/test/xpcshell/test_ext_dnr_tabIds.js does
+// exist, as an isolated xpcshell is needed to verify that the internals are
+// working as expected. A mochitest is not a good fit for that because it has
+// built-in add-ons that may affect the observed behavior.
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.manifestV3.enabled", true],
+ ["extensions.dnr.enabled", true],
+ ],
+ });
+});
+
+add_task(async function match_by_tabIds() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ async function createTabAndPort() {
+ let portPromise = new Promise(resolve => {
+ browser.runtime.onConnect.addListener(function listener(port) {
+ browser.runtime.onConnect.removeListener(listener);
+ browser.test.assertEq("port_from_tab", port.name, "Got port");
+ resolve(port);
+ });
+ });
+ const tab = await browser.tabs.create({ url: "tab.html" });
+ const port = await portPromise;
+ browser.test.assertEq(tab.id, port.sender.tab.id, "Got port from tab");
+ browser.test.assertTrue(tab.id > 0, `tabId must be valid: ${tab.id}`);
+ tab.port = port;
+ return tab;
+ }
+ async function getFinalUrlForFetchInTab(tabWithPort, url) {
+ const port = tabWithPort.port; // from createTabAndPort.
+ return new Promise(resolve => {
+ port.onMessage.addListener(function listener(responseUrl) {
+ port.onMessage.removeListener(listener);
+ resolve(responseUrl);
+ });
+ port.postMessage(url);
+ });
+ }
+ let tab1 = await createTabAndPort();
+ let tab2 = await createTabAndPort();
+
+ const URL_PREFIX = "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.txt";
+
+ function makeRedirect(id, condition, url) {
+ return {
+ id,
+ // The test sends a request to example.net and expects a redirect to
+ // URL_PREFIX (example.com).
+ condition: { requestDomains: ["example.net"], ...condition },
+ action: { type: "redirect", redirect: { url }},
+ };
+ }
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ makeRedirect(1, { tabIds: [-1] }, `${URL_PREFIX}?tabId/-1`),
+ makeRedirect(2, { tabIds: [tab1.id] }, `${URL_PREFIX}?tabId/tab1`),
+ makeRedirect(
+ 3,
+ { excludedTabIds: [-1, tab1.id] },
+ `${URL_PREFIX}?tabId/not-1,not-tab1`
+ ),
+ ],
+ });
+
+ browser.test.assertEq(
+ `${URL_PREFIX}?tabId/-1`,
+ (await fetch("https://example.net/?pre-redirect-bg")).url,
+ "Request from background should match tabIds: [-1]"
+ );
+ browser.test.assertEq(
+ `${URL_PREFIX}?tabId/tab1`,
+ await getFinalUrlForFetchInTab(tab1, "https://example.net/?pre-tab1"),
+ "Request from tab1 should match tabIds: [tab1]"
+ );
+ browser.test.assertEq(
+ `${URL_PREFIX}?tabId/not-1,not-tab1`,
+ await getFinalUrlForFetchInTab(tab2, "https://example.net/?pre-tab2"),
+ "Request from tab2 should match excludedTabIds: [-1, tab1]"
+ );
+
+ await browser.tabs.remove(tab1.id);
+ await browser.tabs.remove(tab2.id);
+
+ browser.test.sendMessage("done");
+ },
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ manifest: {
+ manifest_version: 3,
+ host_permissions: ["*://example.com/*", "*://example.net/*"],
+ permissions: ["declarativeNetRequest"],
+ granted_host_permissions: true,
+ },
+ files: {
+ "tab.html": `<!DOCTYPE html><script src="tab.js"><\/script>`,
+ "tab.js": () => {
+ let port = browser.runtime.connect({ name: "port_from_tab" });
+ port.onMessage.addListener(async url => {
+ try {
+ let res = await fetch(url);
+ port.postMessage(res.url);
+ } catch (e) {
+ port.postMessage(e.message);
+ }
+ });
+ },
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html b/toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html
new file mode 100644
index 0000000000..43bc8a5a00
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html
@@ -0,0 +1,137 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>DNR with upgradeScheme action</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<script>
+"use strict";
+
+// This test is not a xpcshell test, because we want to test upgrades to https,
+// and HttpServer helper does not support https (bug 1742061).
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.manifestV3.enabled", true],
+ ["extensions.dnr.enabled", true],
+ ["extensions.dnr.match_requests_from_other_extensions", true],
+ ],
+ });
+});
+
+// Tests that the upgradeScheme action works as expected:
+// - http should be upgraded to https
+// - after the https upgrade the request should happen instead of being stuck
+// in a upgrade redirect loop.
+add_task(async function upgradeScheme_with_dnr() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [{ id: 1, condition: { requestDomains: ["example.com"] }, action: { type: "upgradeScheme" } }],
+ });
+
+ let sanityCheckResponse = await fetch(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_sample.txt"
+ );
+ browser.test.assertEq(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_sample.txt",
+ sanityCheckResponse.url,
+ "non-matching request should not be upgraded"
+ );
+
+ let res = await fetch(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.txt"
+ );
+ browser.test.assertEq(
+ "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.txt",
+ res.url,
+ "upgradeScheme should have upgraded to https"
+ );
+ // Server adds "Access-Control-Allow-Origin: *" to file_sample.txt, so
+ // we should be able to read the response despite no host_permissions.
+ browser.test.assertEq("Sample", await res.text(), "read body with CORS");
+
+ browser.test.sendMessage("dnr_registered");
+ },
+ manifest: {
+ manifest_version: 3,
+ // Note: host_permissions missing. upgradeScheme should not need it.
+ permissions: ["declarativeNetRequest"],
+ },
+ allowInsecureRequests: true,
+ });
+ await extension.startup();
+ await extension.awaitMessage("dnr_registered");
+
+ // The request made by otherExtension is affected by the DNR rule from the
+ // extension because extensions.dnr.match_requests_from_other_extensions was
+ // set to true. A realistic alternative would have been to trigger the fetch
+ // requests from a content page instead of the extension.
+ let otherExtension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let firstRequestPromise = new Promise(resolve => {
+ let count = 0;
+ browser.webRequest.onBeforeRequest.addListener(
+ ({ url }) => {
+ ++count;
+ browser.test.assertTrue(
+ count <= 2,
+ `Expected at most two requests; got ${count} to ${url}`
+ );
+ resolve(url);
+ },
+ { urls: ["*://example.com/?test_dnr_upgradeScheme"] }
+ );
+ });
+ // Round-trip through ext-webRequest.js implementation to ensure that the
+ // listener has been registered (workaround for bug 1300234).
+ await browser.webRequest.handlerBehaviorChanged();
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const insecureInitialUrl = "http://example.com/?test_dnr_upgradeScheme";
+ browser.test.log(`Requesting insecure URL: ${insecureInitialUrl}`);
+
+ let req = await fetch(insecureInitialUrl);
+ browser.test.assertEq(
+ "https://example.com/?test_dnr_upgradeScheme",
+ req.url,
+ "upgradeScheme action upgraded http to https"
+ );
+ browser.test.assertEq(200, req.status, "Correct HTTP status");
+
+ await req.text(); // Verify that the body can be read, just in case.
+
+ // Sanity check that the test did not pass trivially due to an automatic
+ // https upgrade of the extension / test environment.
+ browser.test.assertEq(
+ insecureInitialUrl,
+ await firstRequestPromise,
+ "Initial URL should be http"
+ );
+
+ browser.test.sendMessage("tested_dnr_upgradeScheme");
+ },
+ manifest: {
+ host_permissions: ["*://example.com/*"],
+ permissions: ["webRequest"],
+ },
+ });
+ await otherExtension.startup();
+ await otherExtension.awaitMessage("tested_dnr_upgradeScheme");
+ await otherExtension.unload();
+
+ await extension.unload();
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html b/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html
new file mode 100644
index 0000000000..23058c35ec
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html
@@ -0,0 +1,94 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Downloads Test</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+async function background() {
+ const url = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html";
+
+ browser.test.assertThrows(
+ () => browser.downloads.download(),
+ /Incorrect argument types for downloads.download/,
+ "Should fail without options"
+ );
+
+ browser.test.assertThrows(
+ () => browser.downloads.download({url: "invalid url"}),
+ /invalid url is not a valid URL/,
+ "Should fail on invalid URL"
+ );
+
+ browser.test.assertThrows(
+ () => browser.downloads.download({}),
+ /Property "url" is required/,
+ "Should fail with no URL"
+ );
+
+ browser.test.assertThrows(
+ () => browser.downloads.download({url, method: "DELETE"}),
+ /Invalid enumeration value "DELETE"/,
+ "Should fail with invalid method"
+ );
+
+ await browser.test.assertRejects(
+ browser.downloads.download({url, headers: [{name: "Host", value: "Banana"}]}),
+ /Forbidden request header name/,
+ "Should fail with a forbidden header"
+ );
+
+ const absoluteFilename = SpecialPowers.Services.appinfo.OS === "WINNT"
+ ? "C:\\tmp\\file.gif"
+ : "/tmp/file.gif";
+
+ await browser.test.assertRejects(
+ browser.downloads.download({url, filename: absoluteFilename}),
+ /filename must not be an absolute path/,
+ "Should fail with an absolute file path"
+ );
+
+ await browser.test.assertRejects(
+ browser.downloads.download({url, filename: ""}),
+ /filename must not be empty/,
+ "Should fail with an empty file path"
+ );
+
+ await browser.test.assertRejects(
+ browser.downloads.download({url, filename: "file."}),
+ /filename must not contain illegal characters/,
+ "Should fail with a dot in the filename"
+ );
+
+ await browser.test.assertRejects(
+ browser.downloads.download({url, filename: "../file.gif"}),
+ /filename must not contain back-references/,
+ "Should fail with a file path that contains back-references"
+ );
+
+ browser.test.notifyPass("download.done");
+}
+
+add_task(async function test_invalid_download_parameters() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {permissions: ["downloads"]},
+ background,
+ });
+ await extension.startup();
+
+ await extension.awaitFinish("download.done");
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html b/toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html
new file mode 100644
index 0000000000..d6702da4d3
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html
@@ -0,0 +1,94 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test checking webRequest.onBeforeRequest details object</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+let expected = {
+ "file_contains_iframe.html": {
+ type: "main_frame",
+ frameAncestor_length: 0,
+ },
+ "file_contains_img.html": {
+ type: "sub_frame",
+ frameAncestor_length: 1,
+ },
+ "file_image_good.png": {
+ type: "image",
+ frameAncestor_length: 1,
+ }
+};
+
+function checkDetails(details) {
+ let url = new URL(details.url);
+ let filename = url.pathname.split("/").pop();
+ ok(expected.hasOwnProperty(filename), `Should be expecting a request for ${filename}`);
+ let expect = expected[filename];
+ is(expect.type, details.type, `${details.type} type matches`);
+ is(expect.frameAncestor_length, details.frameAncestors.length, "incorrect frameAncestors length");
+ if (filename == "file_contains_img.html") {
+ is(details.frameAncestors[0].frameId, details.parentFrameId,
+ "frameAncestors[0] should match parentFrameId");
+ expected["file_image_good.png"].frameId = details.frameId;
+ } else if (filename == "file_image_good.png") {
+ is(details.frameAncestors[0].frameId, details.parentFrameId,
+ "frameAncestors[0] should match parentFrameId");
+ is(details.frameId, expect.frameId,
+ "frameId for image and iframe should match");
+ }
+}
+
+add_task(async () => {
+ // Clear the image cache, since it gets in the way otherwise.
+ let imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools);
+ let cache = imgTools.getImgCacheForDocument(document);
+ cache.clearCache(false);
+ await SpecialPowers.spawnChrome([], async () => {
+ Services.cache2.clear();
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "<all_urls>"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("onBeforeRequest", details);
+ },
+ {
+ urls: [
+ "http://example.org/*/file_contains_img.html",
+ "http://mochi.test/*/file_contains_iframe.html",
+ "*://*/*.png",
+ ],
+ }
+ );
+ },
+ });
+
+ await extension.startup();
+ const FILE_URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html";
+ let win = window.open(FILE_URL);
+ await new Promise(resolve => win.addEventListener("load", () => resolve(), {once: true}));
+
+ for (let i = 0; i < Object.keys(expected).length; i++) {
+ checkDetails(await extension.awaitMessage("onBeforeRequest"));
+ }
+
+ win.close();
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html b/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html
new file mode 100644
index 0000000000..f87b5620d6
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html
@@ -0,0 +1,91 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_contentscript() {
+ function background() {
+ browser.runtime.onMessage.addListener(([script], sender) => {
+ browser.test.sendMessage("run", {script});
+ browser.test.sendMessage("run-" + script);
+ });
+ browser.test.sendMessage("running");
+ }
+
+ function contentScriptAll() {
+ browser.runtime.sendMessage(["all"]);
+ }
+ function contentScriptIncludesTest1() {
+ browser.runtime.sendMessage(["includes-test1"]);
+ }
+ function contentScriptExcludesTest1() {
+ browser.runtime.sendMessage(["excludes-test1"]);
+ }
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ "matches": ["https://example.org/", "https://*.example.org/"],
+ "exclude_globs": [],
+ "include_globs": ["*"],
+ "js": ["content_script_all.js"],
+ },
+ {
+ "matches": ["https://example.org/", "https://*.example.org/"],
+ "include_globs": ["*test1*"],
+ "js": ["content_script_includes_test1.js"],
+ },
+ {
+ "matches": ["https://example.org/", "https://*.example.org/"],
+ "exclude_globs": ["*test1*"],
+ "js": ["content_script_excludes_test1.js"],
+ },
+ ],
+ },
+ background,
+
+ files: {
+ "content_script_all.js": contentScriptAll,
+ "content_script_includes_test1.js": contentScriptIncludesTest1,
+ "content_script_excludes_test1.js": contentScriptExcludesTest1,
+ },
+
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ let ran = 0;
+ extension.onMessage("run", ({script}) => {
+ ran++;
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("running")]);
+ info("extension loaded");
+
+ let win = window.open("https://example.org/");
+ await Promise.all([extension.awaitMessage("run-all"), extension.awaitMessage("run-excludes-test1")]);
+ win.close();
+ is(ran, 2);
+
+ win = window.open("https://test1.example.org/");
+ await Promise.all([extension.awaitMessage("run-all"), extension.awaitMessage("run-includes-test1")]);
+ win.close();
+ is(ran, 4);
+
+ await extension.unload();
+ info("extension unloaded");
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_extension_iframe_messaging.html b/toolkit/components/extensions/test/mochitest/test_ext_extension_iframe_messaging.html
new file mode 100644
index 0000000000..403782ab7d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_extension_iframe_messaging.html
@@ -0,0 +1,124 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test moz-extension iframe messaging</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<script>
+"use strict";
+
+add_task(async function test_moz_extension_iframe_messaging() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.content_web_accessible.enabled", true],
+ ],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [{
+ js: ["cs.js"],
+ matches: ["http://mochi.test/*/file_sample.html"],
+ }],
+ web_accessible_resources: ["iframe.html"],
+ permissions: ["tabs"],
+ },
+ files: {
+ "cs.js"() {
+ let iframe = document.createElement("iframe");
+ iframe.src = browser.runtime.getURL("iframe.html");
+ document.body.append(iframe);
+ },
+
+ "iframe.html": `<!doctype html><script src=iframe.js><\/script>`,
+ async "iframe.js"() {
+ browser.runtime.onMessage.addListener(async msg => {
+ browser.test.assertEq(msg, "from-background", "Correct message.");
+ return "iframe-response";
+ });
+
+ browser.runtime.onConnect.addListener(async port => {
+ port.postMessage("port-message");
+ });
+
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage("from-iframe"),
+ "Could not establish connection. Receiving end does not exist.",
+ "No onMessage listener in the background."
+ );
+
+ await new Promise(resolve => {
+ let port = browser.runtime.connect();
+ port.onDisconnect.addListener(() => {
+ browser.test.assertEq(
+ port.error.message,
+ "Could not establish connection. Receiving end does not exist.",
+ "No onConnect listener in the background."
+ );
+ resolve();
+ })
+ });
+
+ // TODO: If/when the tabs API is available from extension iframes, test
+ // that it won't send a message to itself via browser.tabs.sendMessage()
+ browser.test.assertEq(browser.tabs, undefined, "No tabs API");
+
+ browser.test.sendMessage("iframe-done");
+ },
+ },
+ background() {
+ browser.test.onMessage.addListener(async msg => {
+
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage("from-background"),
+ "Could not establish connection. Receiving end does not exist.",
+ "No onMessage listener in another extension page."
+ );
+
+ await new Promise(resolve => {
+ let port = browser.runtime.connect();
+ port.onDisconnect.addListener(() => {
+ browser.test.assertEq(
+ port.error.message,
+ "Could not establish connection. Receiving end does not exist.",
+ "No onConnect listener in another extension page."
+ );
+ resolve();
+ })
+ });
+
+ let [tab] = await browser.tabs.query({
+ url: "http://mochi.test/*/file_sample.html",
+ });
+ let res = await browser.tabs.sendMessage(tab.id, "from-background");
+ browser.test.assertEq(res, "iframe-response", "Correct response.");
+
+ let port = browser.tabs.connect(tab.id);
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, "port-message", "Correct port message.");
+ browser.test.notifyPass("done");
+ });
+ })
+ }
+ });
+
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+ await extension.awaitMessage("iframe-done");
+
+ extension.sendMessage("run-background");
+ await extension.awaitFinish("done");
+ win.close();
+
+ await extension.unload();
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html b/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html
new file mode 100644
index 0000000000..639cacef28
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html
@@ -0,0 +1,110 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension external messaging</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function backgroundScript(id, otherId) {
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ browser.test.fail(`Got unexpected message: ${uneval(msg)} ${uneval(sender)}`);
+ });
+
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.fail(`Got unexpected connection: ${uneval(port.sender)}`);
+ });
+
+ browser.runtime.onMessageExternal.addListener((msg, sender) => {
+ browser.test.assertEq(otherId, sender.id, `${id}: Got expected external sender ID`);
+ browser.test.assertEq(`helo-${id}`, msg, "Got expected message");
+
+ browser.test.sendMessage("onMessage-done");
+
+ return Promise.resolve(`ehlo-${otherId}`);
+ });
+
+ browser.runtime.onConnectExternal.addListener(port => {
+ browser.test.assertEq(otherId, port.sender.id, `${id}: Got expected external connecter ID`);
+
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(`helo-${id}`, msg, "Got expected port message");
+
+ port.postMessage(`ehlo-${otherId}`);
+
+ browser.test.sendMessage("onConnect-done");
+ });
+ });
+
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "go") {
+ browser.runtime.sendMessage(otherId, `helo-${otherId}`).then(result => {
+ browser.test.assertEq(`ehlo-${id}`, result, "Got expected reply");
+ browser.test.sendMessage("sendMessage-done");
+ });
+
+ let port = browser.runtime.connect(otherId);
+ port.postMessage(`helo-${otherId}`);
+
+ port.onMessage.addListener(msg => {
+ port.disconnect();
+
+ browser.test.assertEq(msg, `ehlo-${id}`, "Got expected port reply");
+ browser.test.sendMessage("connect-done");
+ });
+ }
+ });
+}
+
+function makeExtension(id, otherId) {
+ let args = `${JSON.stringify(id)}, ${JSON.stringify(otherId)}`;
+
+ let extensionData = {
+ background: `(${backgroundScript})(${args})`,
+ manifest: {
+ browser_specific_settings: {gecko: {id}},
+ },
+ };
+
+ return ExtensionTestUtils.loadExtension(extensionData);
+}
+
+add_task(async function test_contentscript() {
+ const ID1 = "foo-message@mochitest.mozilla.org";
+ const ID2 = "bar-message@mochitest.mozilla.org";
+
+ let extension1 = makeExtension(ID1, ID2);
+ let extension2 = makeExtension(ID2, ID1);
+
+ await Promise.all([extension1.startup(), extension2.startup()]);
+
+ extension1.sendMessage("go");
+ extension2.sendMessage("go");
+
+ await Promise.all([
+ extension1.awaitMessage("sendMessage-done"),
+ extension2.awaitMessage("sendMessage-done"),
+
+ extension1.awaitMessage("onMessage-done"),
+ extension2.awaitMessage("onMessage-done"),
+
+ extension1.awaitMessage("connect-done"),
+ extension2.awaitMessage("connect-done"),
+
+ extension1.awaitMessage("onConnect-done"),
+ extension2.awaitMessage("onConnect-done"),
+ ]);
+
+ await extension1.unload();
+ await extension2.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_generate.html b/toolkit/components/extensions/test/mochitest/test_ext_generate.html
new file mode 100644
index 0000000000..ba88d16ca3
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_generate.html
@@ -0,0 +1,48 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for generating WebExtensions</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function background() {
+ browser.test.log("running background script");
+
+ browser.test.onMessage.addListener((x, y) => {
+ browser.test.assertEq(x, 10, "x is 10");
+ browser.test.assertEq(y, 20, "y is 20");
+
+ browser.test.notifyPass("background test passed");
+ });
+
+ browser.test.sendMessage("running", 1);
+}
+
+let extensionData = {
+ background,
+};
+
+add_task(async function test_background() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ info("load complete");
+ let [, x] = await Promise.all([extension.startup(), extension.awaitMessage("running")]);
+ is(x, 1, "got correct value from extension");
+ info("startup complete");
+ extension.sendMessage(10, 20);
+ await extension.awaitFinish();
+ info("test complete");
+ await extension.unload();
+ info("extension unloaded successfully");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html b/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html
new file mode 100644
index 0000000000..9f326372bb
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html
@@ -0,0 +1,86 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+add_task(async function test_geolocation_nopermission() {
+ let GEO_URL = "http://mochi.test:8888/tests/dom/tests/mochitest/geolocation/network_geolocation.sjs";
+ await SpecialPowers.pushPrefEnv({"set": [["geo.provider.network.url", GEO_URL]]});
+});
+
+add_task(async function test_geolocation() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "geolocation",
+ ],
+ },
+ background() {
+ navigator.geolocation.getCurrentPosition(() => {
+ browser.test.notifyPass("success geolocation call");
+ }, (error) => {
+ browser.test.notifyFail(`geolocation call ${error}`);
+ });
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_geolocation_nopermission() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ navigator.geolocation.getCurrentPosition(() => {
+ browser.test.notifyFail("success geolocation call");
+ }, (error) => {
+ browser.test.notifyPass(`geolocation call ${error}`);
+ });
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_geolocation_prompt() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.tabs.create({url: "tab.html"});
+ },
+ files: {
+ "tab.html": `<html><head>
+ <meta charset="utf-8">
+ <script src="tab.js"><\/script>
+ </head></html>`,
+ "tab.js": () => {
+ navigator.geolocation.getCurrentPosition(() => {
+ browser.test.notifyPass("success geolocation call");
+ }, (error) => {
+ browser.test.notifyFail(`geolocation call ${error}`);
+ });
+ },
+ },
+ });
+
+ // Bypass the actual prompt, but the prompt result is to allow access.
+ await SpecialPowers.pushPrefEnv({"set": [["geo.prompt.testing", true], ["geo.prompt.testing.allow", true]]});
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+</script>
+</head>
+<body>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_identity.html b/toolkit/components/extensions/test/mochitest/test_ext_identity.html
new file mode 100644
index 0000000000..7aa590ec22
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_identity.html
@@ -0,0 +1,390 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for WebExtension Identity</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webextensions.identity.redirectDomain", "example.com"],
+ // Disable the network cache first-party partition during this
+ // test (TODO: look more closely to how that is affecting the intermittency
+ // of this test on MacOS, see Bug 1626482).
+ ["privacy.partition.network_state", false],
+ ],
+ });
+});
+
+add_task(async function test_noPermission() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.assertEq(
+ undefined,
+ browser.identity,
+ "No identity api without permission"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_getRedirectURL() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["identity", "https://example.com/"],
+ },
+ async background() {
+ let redirect_base =
+ "https://35b64b676900f491c00e7f618d43f7040e88422e.example.com/";
+ await browser.test.assertEq(
+ redirect_base,
+ browser.identity.getRedirectURL(),
+ "redirect url ok"
+ );
+ await browser.test.assertEq(
+ redirect_base,
+ browser.identity.getRedirectURL(""),
+ "redirect url ok"
+ );
+ await browser.test.assertEq(
+ redirect_base + "foobar",
+ browser.identity.getRedirectURL("foobar"),
+ "redirect url ok"
+ );
+ await browser.test.assertEq(
+ redirect_base + "callback",
+ browser.identity.getRedirectURL("/callback"),
+ "redirect url ok"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_badAuthURI() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["identity", "https://example.com/"],
+ },
+ async background() {
+ for (let url of [
+ "foobar",
+ "about:addons",
+ "about:blank",
+ "ftp://example.com/test",
+ ]) {
+ await browser.test.assertThrows(
+ () => {
+ browser.identity.launchWebAuthFlow({ interactive: true, url });
+ },
+ /Type error for parameter details/,
+ "details.url is invalid"
+ );
+ }
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_badRequestURI() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["identity", "https://example.com/"],
+ },
+ async background() {
+ let base_uri =
+ "https://example.com/tests/toolkit/components/extensions/test/mochitest/";
+ let url = `${base_uri}?redirect_uri=badrobot}`;
+ await browser.test.assertRejects(
+ browser.identity.launchWebAuthFlow({ interactive: true, url }),
+ "redirect_uri is invalid",
+ "invalid redirect url"
+ );
+ url = `${base_uri}?redirect_uri=https://somesite.com`;
+ await browser.test.assertRejects(
+ browser.identity.launchWebAuthFlow({ interactive: true, url }),
+ "redirect_uri not allowed",
+ "invalid redirect url"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function background_launchWebAuthFlow_requires_interaction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["identity", "https://example.com/"],
+ },
+ async background() {
+ let base_uri =
+ "https://example.com/tests/toolkit/components/extensions/test/mochitest/";
+ let url = `${base_uri}?redirect_uri=${browser.identity.getRedirectURL(
+ "redirect"
+ )}`;
+ await browser.test.assertRejects(
+ browser.identity.launchWebAuthFlow({ interactive: false, url }),
+ "Requires user interaction",
+ "Rejects on required user interaction"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+function background_launchWebAuthFlow({
+ interactive = false,
+ path = "redirect_auto.sjs",
+ params = {},
+ redirect = true,
+ useRedirectUri = true,
+} = {}) {
+ let uri_path = useRedirectUri ? "identity_cb" : "";
+ let expected_redirect = `https://35b64b676900f491c00e7f618d43f7040e88422e.example.com/${uri_path}`;
+ let base_uri =
+ "https://example.com/tests/toolkit/components/extensions/test/mochitest/";
+ let redirect_uri = browser.identity.getRedirectURL(
+ useRedirectUri ? uri_path : undefined
+ );
+ browser.test.assertEq(
+ expected_redirect,
+ redirect_uri,
+ "expected redirect uri matches hash"
+ );
+ let url = `${base_uri}${path}`;
+ if (useRedirectUri) {
+ params.redirect_uri = redirect_uri;
+ } else {
+ // We kind of fake it with the redirect url that would normally be configured
+ // in the oauth service. This does still test that the identity service falls back
+ // to the extensions redirect url.
+ params.default_redirect = expected_redirect;
+ }
+ if (!redirect) {
+ params.no_redirect = 1;
+ }
+ let query = [];
+ for (let [param, value] of Object.entries(params)) {
+ query.push(`${param}=${encodeURIComponent(value)}`);
+ }
+ url = `${url}?${query.join("&")}`;
+
+ // Ensure we do not start the actual request for the redirect url. In the case
+ // of a 303 POST redirect we are getting a request started.
+ let watchRedirectRequest = () => {};
+ if (params.post !== 303) {
+ watchRedirectRequest = details => {
+ if (details.url.startsWith(expected_redirect)) {
+ browser.test.fail(`onBeforeRequest called for redirect url: ${JSON.stringify(details)}`);
+ }
+ };
+
+ browser.webRequest.onBeforeRequest.addListener(
+ watchRedirectRequest,
+ {
+ urls: [
+ "https://35b64b676900f491c00e7f618d43f7040e88422e.example.com/*",
+ ],
+ }
+ );
+ }
+
+ browser.identity
+ .launchWebAuthFlow({ interactive, url })
+ .then(redirectURL => {
+ browser.test.assertTrue(
+ redirectURL.startsWith(redirect_uri),
+ `correct redirect url ${redirectURL}`
+ );
+ if (redirect) {
+ let url = new URL(redirectURL);
+ browser.test.assertEq(
+ "here ya go",
+ url.searchParams.get("access_token"),
+ "Handled auto redirection"
+ );
+ }
+ })
+ .catch(error => {
+ if (redirect) {
+ browser.test.fail(error.message);
+ } else {
+ browser.test.assertEq(
+ "Requires user interaction",
+ error.message,
+ "Auth page loaded, interaction required."
+ );
+ }
+ }).then(() => {
+ browser.webRequest.onBeforeRequest.removeListener(watchRedirectRequest);
+ browser.test.sendMessage("done");
+ });
+}
+
+// Tests the situation where the oauth provider has already granted access and
+// simply redirects the oauth client to provide the access key or code.
+add_task(async function test_autoRedirect() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["webRequest", "identity", "https://*.example.com/*"],
+ },
+ background: `(${background_launchWebAuthFlow})()`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_autoRedirect_noRedirectURI() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["webRequest", "identity", "https://*.example.com/*"],
+ },
+ background: `(${background_launchWebAuthFlow})({useRedirectUri: false})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+// Tests the situation where the oauth provider has not granted access and interactive=false
+add_task(async function test_noRedirect() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["webRequest", "identity", "https://*.example.com/*"],
+ },
+ background: `(${background_launchWebAuthFlow})({redirect: false})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+// Tests the situation where the oauth provider must show a window where
+// presumably the user interacts, then the redirect occurs and access key or
+// code is provided. We bypass any real interaction, but want the window to
+// open and result in a redirect.
+add_task(async function test_interaction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["webRequest", "identity", "https://*.example.com/*"],
+ },
+ background: `(${background_launchWebAuthFlow})({interactive: true, path: "oauth.html"})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+// Tests the situation where the oauth provider redirects with a 303.
+add_task(async function test_auto303Redirect() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["webRequest", "identity", "https://*.example.com/*"],
+ },
+ background: `(${background_launchWebAuthFlow})({interactive: true, path: "oauth.html", params: {post: 303, server_uri: "redirect_auto.sjs"}})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_loopbackRedirectURI() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["identity"],
+ },
+ async background() {
+ let redirectURL = "http://127.0.0.1/mozoauth2/35b64b676900f491c00e7f618d43f7040e88422e";
+ let actualRedirect = await browser.identity.launchWebAuthFlow({
+ interactive: true,
+ url: `https://example.com/tests/toolkit/components/extensions/test/mochitest/oauth.html?redirect_uri=${encodeURIComponent(redirectURL)}`
+ }).catch(error => {
+ browser.test.fail(error.message)
+ });
+ browser.test.assertTrue(
+ actualRedirect.startsWith(redirectURL),
+ "Expected redirect url to be loopback address"
+ )
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_idle.html b/toolkit/components/extensions/test/mochitest/test_ext_idle.html
new file mode 100644
index 0000000000..381687ee38
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_idle.html
@@ -0,0 +1,68 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function testWithRealIdleService() {
+ function background() {
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ let detectionInterval = args[0];
+ if (msg == "addListener") {
+ let status = await browser.idle.queryState(detectionInterval);
+ browser.test.assertEq("active", status, "Idle status is active");
+ browser.idle.setDetectionInterval(detectionInterval);
+ browser.idle.onStateChanged.addListener(newState => {
+ browser.test.assertEq("idle", newState, "listener fired with the expected state");
+ browser.test.sendMessage("listenerFired");
+ });
+ browser.test.sendMessage("listenerAdded");
+ } else if (msg == "checkState") {
+ let status = await browser.idle.queryState(detectionInterval);
+ browser.test.assertEq("idle", status, "Idle status is idle");
+ browser.test.notifyPass("idle");
+ }
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ await extension.startup();
+
+ let chromeScript = loadChromeScript(() => {
+ const {sendAsyncMessage} = this;
+ const idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService(Ci.nsIUserIdleService);
+ let idleTime = idleService.idleTime;
+ sendAsyncMessage("detectionInterval", Math.max(Math.ceil(idleTime / 1000) + 10, 15));
+ });
+ let detectionInterval = await chromeScript.promiseOneMessage("detectionInterval");
+ chromeScript.destroy();
+
+ info(`Setting interval to ${detectionInterval}`);
+ extension.sendMessage("addListener", detectionInterval);
+ await extension.awaitMessage("listenerAdded");
+ info("Listener added");
+ await extension.awaitMessage("listenerFired");
+ info("Listener fired");
+ extension.sendMessage("checkState", detectionInterval);
+ await extension.awaitFinish("idle");
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html b/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html
new file mode 100644
index 0000000000..5b36902581
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html
@@ -0,0 +1,49 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_in_incognito_context_true() {
+ function background() {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.assertEq(true, msg, "inIncognitoContext is true");
+ browser.test.notifyPass("inIncognitoContext");
+ });
+
+ browser.windows.create({url: browser.runtime.getURL("/tab.html"), incognito: true});
+ }
+
+ function tabScript() {
+ browser.runtime.sendMessage(browser.extension.inIncognitoContext);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ files: {
+ "tab.js": tabScript,
+ "tab.html": `<!DOCTYPE html><html><head>
+ <meta charset="utf-8">
+ <script src="tab.js"><\/script>
+ </head></html>`,
+ },
+ incognitoOverride: "spanning",
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("inIncognitoContext");
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html b/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html
new file mode 100644
index 0000000000..cc161f735f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html
@@ -0,0 +1,62 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_listener_proxies() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+
+ manifest: {
+ "permissions": ["storage"],
+ },
+
+ async background() {
+ // Test that adding multiple listeners for the same event works as
+ // expected.
+
+ let awaitChanged = () => new Promise(resolve => {
+ browser.storage.onChanged.addListener(function listener() {
+ browser.storage.onChanged.removeListener(listener);
+ resolve();
+ });
+ });
+
+ let promises = [
+ awaitChanged(),
+ awaitChanged(),
+ ];
+
+ function removedListener() {}
+ browser.storage.onChanged.addListener(removedListener);
+ browser.storage.onChanged.removeListener(removedListener);
+
+ promises.push(awaitChanged(), awaitChanged());
+
+ browser.storage.local.set({foo: "bar"});
+
+ await Promise.all(promises);
+
+ browser.test.notifyPass("onchanged-listeners");
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitFinish("onchanged-listeners");
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html b/toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html
new file mode 100644
index 0000000000..2c5ae1c0ba
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html
@@ -0,0 +1,168 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test for opening links in new tabs from extension frames</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function promiseObserved(topic, check) {
+ return new Promise(resolve => {
+ let obs = SpecialPowers.Services.obs;
+
+ function observer(subject, topic, data) {
+ subject = SpecialPowers.wrap(subject);
+ if (check(subject, data)) {
+ obs.removeObserver(observer, topic);
+ resolve({subject, data});
+ }
+ }
+ obs.addObserver(observer, topic);
+ });
+}
+
+add_task(async function test_target_blank_link_no_opener_from_privileged() {
+ const linkURL = "https://example.com/";
+
+ function extension_tab() {
+ document.getElementById("link").click();
+ }
+
+ function content_script() {
+ browser.runtime.sendMessage("content_page_loaded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [{
+ js: ["content_script.js"],
+ matches: ["https://example.com/*"],
+ run_at: "document_idle",
+ }],
+ permissions: ["tabs"],
+ },
+ files: {
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head><meta charset="utf-8"></html>
+ <body>
+ <a href="${linkURL}" target="_blank" id="link">link</a>
+ <script src="extension_tab.js"><\/script>
+ </body>
+ </html>`,
+ "extension_tab.js": extension_tab,
+ "content_script.js": content_script,
+ },
+ async background() {
+ let pageTab;
+ browser.test.onMessage.addListener(async (msg) => {
+ if (msg !== "close_tab") {
+ browser.test.fail("Unexpected test message: " + msg);
+ return;
+ }
+ if (!pageTab) {
+ browser.test.fail("Unexpected close-tab test message received when there is no pageTab");
+ return;
+ }
+ await browser.tabs.remove(pageTab.id);
+ browser.test.sendMessage("close_tab_done");
+ });
+ browser.runtime.onMessage.addListener(async (msg, sender) => {
+ if (sender.tab) {
+ await browser.tabs.remove(sender.tab.id);
+ browser.test.sendMessage(msg, sender.tab.url);
+ }
+ });
+ pageTab = await browser.tabs.create({ url: browser.runtime.getURL("page.html") });
+ browser.test.sendMessage("tab_created");
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("tab_created");
+
+ // Make sure page is loaded correctly
+ const url = await extension.awaitMessage("content_page_loaded");
+ is(url, linkURL, "Page URL should match");
+
+ // Clean up opened tab.
+ extension.sendMessage("close_tab");
+ await extension.awaitMessage("close_tab_done");
+
+ await extension.unload();
+});
+
+add_task(async function test_target_blank_link() {
+ const linkURL = "http://mochi.test:8888/tests/toolkit/";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_security_policy: "script-src 'self' 'unsafe-eval'; object-src 'self';",
+
+ web_accessible_resources: ["iframe.html"],
+ },
+ files: {
+ "iframe.html": `<!DOCTYPE html>
+ <html>
+ <head><meta charset="utf-8"></html>
+ <body>
+ <a href="${linkURL}" target="_blank" id="link" rel="opener">link</a>
+ </body>
+ </html>`,
+ },
+ background() {
+ browser.test.sendMessage("frame_url", browser.runtime.getURL("iframe.html"));
+ },
+ });
+
+ await extension.startup();
+
+ let url = await extension.awaitMessage("frame_url");
+
+ let iframe = document.createElement("iframe");
+ iframe.src = url;
+ document.body.appendChild(iframe);
+ await new Promise(resolve => iframe.addEventListener("load", () => setTimeout(resolve, 0), {once: true}));
+
+ let win = SpecialPowers.wrap(iframe).contentWindow;
+
+ {
+ // Flush layout so that synthesizeMouseAtCenter on a cross-origin iframe
+ // works as expected.
+ document.body.getBoundingClientRect();
+
+ let promise = promiseObserved("document-element-inserted", doc => doc.documentURI === linkURL);
+
+ await SpecialPowers.spawn(iframe, [], async () => {
+ this.content.document.getElementById("link").click();
+ });
+
+ let {subject: doc} = await promise;
+ info("Link opened");
+ doc.defaultView.close();
+ info("Window closed");
+ }
+
+ {
+ let promise = promiseObserved("document-element-inserted", doc => doc.documentURI === linkURL);
+
+ let res = win.eval(`window.open("${linkURL}")`);
+ let {subject: doc} = await promise;
+ is(SpecialPowers.unwrap(res), SpecialPowers.unwrap(doc.defaultView), "window.open worked as expected");
+
+ doc.defaultView.close();
+ }
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_notifications.html b/toolkit/components/extensions/test/mochitest/test_ext_notifications.html
new file mode 100644
index 0000000000..7a91320373
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_notifications.html
@@ -0,0 +1,340 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for notifications</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head_notifications.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// A 1x1 PNG image.
+// Source: https://commons.wikimedia.org/wiki/File:1x1.png (Public Domain)
+let image = atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" +
+ "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=");
+const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)).buffer;
+
+add_task(async function setup_mock_alert_service() {
+ await MockAlertsService.register();
+});
+
+add_task(async function test_notification() {
+ async function background() {
+ let opts = {
+ type: "basic",
+ title: "Testing Notification",
+ message: "Carry on",
+ };
+
+ let id = await browser.notifications.create(opts);
+
+ browser.test.sendMessage("running", id);
+ browser.test.notifyPass("background test passed");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ });
+ await extension.startup();
+ let x = await extension.awaitMessage("running");
+ is(x, "0", "got correct id from notifications.create");
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_notification_events() {
+ async function background() {
+ let opts = {
+ type: "basic",
+ title: "Testing Notification",
+ message: "Carry on",
+ };
+
+ let createdId = "98";
+
+ // Test an ignored listener.
+ browser.notifications.onButtonClicked.addListener(function() {});
+
+ // We cannot test onClicked listener without a mock
+ // but we can attempt to add a listener.
+ browser.notifications.onClicked.addListener(async function(id) {
+ browser.test.assertEq(createdId, id, "onClicked has the expected ID");
+ browser.test.sendMessage("notification-event", "clicked");
+ });
+
+ browser.notifications.onShown.addListener(async function listener(id) {
+ browser.test.assertEq(createdId, id, "onShown has the expected ID");
+ browser.test.sendMessage("notification-event", "shown");
+ });
+
+ browser.test.onMessage.addListener(async function(msg, expectedCount) {
+ if (msg === "create-again") {
+ let newId = await browser.notifications.create(createdId, opts);
+ browser.test.assertEq(createdId, newId, "create returned the expected id.");
+ browser.test.sendMessage("notification-created-twice");
+ } else if (msg === "check-count") {
+ let notifications = await browser.notifications.getAll();
+ let ids = Object.keys(notifications);
+ browser.test.assertEq(expectedCount, ids.length, `getAll() = ${ids}`);
+ browser.test.sendMessage("check-count-result");
+ }
+ });
+
+ // Test onClosed listener.
+ browser.notifications.onClosed.addListener(function listener(id) {
+ browser.test.assertEq(createdId, id, "onClosed received the expected id.");
+ browser.test.sendMessage("notification-event", "closed");
+ });
+
+ await browser.notifications.create(createdId, opts);
+
+ browser.test.sendMessage("notification-created-once");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ async function waitForNotificationEvent(name) {
+ info(`Waiting for notification event: ${name}`);
+ is(name, await extension.awaitMessage("notification-event"),
+ "Expected notification event");
+ }
+ async function checkNotificationCount(expectedCount) {
+ extension.sendMessage("check-count", expectedCount);
+ await extension.awaitMessage("check-count-result");
+ }
+
+ await extension.awaitMessage("notification-created-once");
+ await waitForNotificationEvent("shown");
+ await checkNotificationCount(1);
+
+ // On most platforms, clicking the notification closes it.
+ // But on macOS, the notification can repeatedly be clicked without closing.
+ await MockAlertsService.clickNotificationsWithoutClose();
+ await waitForNotificationEvent("clicked");
+ await checkNotificationCount(1);
+ await MockAlertsService.clickNotificationsWithoutClose();
+ await waitForNotificationEvent("clicked");
+ await checkNotificationCount(1);
+ await MockAlertsService.clickNotifications();
+ await waitForNotificationEvent("clicked");
+ await waitForNotificationEvent("closed");
+ await checkNotificationCount(0);
+
+ extension.sendMessage("create-again");
+ await extension.awaitMessage("notification-created-twice");
+ await waitForNotificationEvent("shown");
+ await checkNotificationCount(1);
+
+ await MockAlertsService.closeNotifications();
+ await waitForNotificationEvent("closed");
+ await checkNotificationCount(0);
+
+ await extension.unload();
+});
+
+add_task(async function test_notification_clear() {
+ function background() {
+ let opts = {
+ type: "basic",
+ title: "Testing Notification",
+ message: "Carry on",
+ };
+
+ let createdId = "99";
+
+ browser.notifications.onShown.addListener(async id => {
+ browser.test.assertEq(createdId, id, "onShown received the expected id.");
+ let wasCleared = await browser.notifications.clear(id);
+ browser.test.assertTrue(wasCleared, "notifications.clear returned true.");
+ });
+
+ browser.notifications.onClosed.addListener(id => {
+ browser.test.assertEq(createdId, id, "onClosed received the expected id.");
+ browser.test.notifyPass("background test passed");
+ });
+
+ browser.notifications.create(createdId, opts);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_notifications_empty_getAll() {
+ async function background() {
+ let notifications = await browser.notifications.getAll();
+
+ browser.test.assertEq("object", typeof notifications, "getAll() returned an object");
+ browser.test.assertEq(0, Object.keys(notifications).length, "the object has no properties");
+ browser.test.notifyPass("getAll empty");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ });
+ await extension.startup();
+ await extension.awaitFinish("getAll empty");
+ await extension.unload();
+});
+
+add_task(async function test_notifications_populated_getAll() {
+ async function background() {
+ let opts = {
+ type: "basic",
+ iconUrl: "a.png",
+ title: "Testing Notification",
+ message: "Carry on",
+ };
+
+ await browser.notifications.create("p1", opts);
+ await browser.notifications.create("p2", opts);
+ let notifications = await browser.notifications.getAll();
+
+ browser.test.assertEq("object", typeof notifications, "getAll() returned an object");
+ browser.test.assertEq(2, Object.keys(notifications).length, "the object has 2 properties");
+
+ for (let notificationId of ["p1", "p2"]) {
+ for (let key of Object.keys(opts)) {
+ browser.test.assertEq(
+ opts[key],
+ notifications[notificationId][key],
+ `the notification has the expected value for option: ${key}`
+ );
+ }
+ }
+
+ browser.test.notifyPass("getAll populated");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ files: {
+ "a.png": IMAGE_ARRAYBUFFER,
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("getAll populated");
+ await extension.unload();
+});
+
+add_task(async function test_buttons_unsupported() {
+ function background() {
+ let opts = {
+ type: "basic",
+ title: "Testing Notification",
+ message: "Carry on",
+ buttons: [{title: "Button title"}],
+ };
+
+ let exception = {};
+ try {
+ browser.notifications.create(opts);
+ } catch (e) {
+ exception = e;
+ }
+
+ browser.test.assertTrue(
+ String(exception).includes('Property "buttons" is unsupported by Firefox'),
+ "notifications.create with buttons option threw an expected exception"
+ );
+ browser.test.notifyPass("buttons-unsupported");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ });
+ await extension.startup();
+ await extension.awaitFinish("buttons-unsupported");
+ await extension.unload();
+});
+
+add_task(async function test_notifications_different_contexts() {
+ async function background() {
+ let opts = {
+ type: "basic",
+ title: "Testing Notification",
+ message: "Carry on",
+ };
+
+ let id = await browser.notifications.create(opts);
+
+ browser.runtime.onMessage.addListener(async (message, sender) => {
+ await browser.tabs.remove(sender.tab.id);
+
+ // We should be able to clear the notification after creating and
+ // destroying the tab.html page.
+ let wasCleared = await browser.notifications.clear(id);
+ browser.test.assertTrue(wasCleared, "The notification was cleared.");
+ browser.test.notifyPass("notifications");
+ });
+
+ browser.tabs.create({url: browser.runtime.getURL("/tab.html")});
+ }
+
+ async function tabScript() {
+ // We should be able to see the notification created in the background page
+ // in this page.
+ let notifications = await browser.notifications.getAll();
+ browser.test.assertEq(1, Object.keys(notifications).length,
+ "One notification found.");
+ browser.runtime.sendMessage("continue-test");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ files: {
+ "tab.js": tabScript,
+ "tab.html": `<!DOCTYPE html><html><head>
+ <meta charset="utf-8">
+ <script src="tab.js"><\/script>
+ </head></html>`,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("notifications");
+ await extension.unload();
+});
+
+add_task(async function teardown_mock_alert_service() {
+ await MockAlertsService.unregister();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_optional_permissions.html b/toolkit/components/extensions/test/mochitest/test_ext_optional_permissions.html
new file mode 100644
index 0000000000..659a55f5c9
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_optional_permissions.html
@@ -0,0 +1,98 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>optional permissions and preloaded processes</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webextOptionalPermissionPrompts", false]],
+ });
+});
+
+// This test case verifies that newly granted optional permissions are
+// propagated to all processes, especially preloaded processes.
+add_task(async function test_optional_permissions_should_be_propagated() {
+ let anOptionalPermission = "*://example.org/*";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "scripting",
+ "*://example.com/*",
+ ],
+ optional_permissions: [anOptionalPermission],
+ },
+ async background() {
+ browser.test.onMessage.addListener(async (msg, value) => {
+ browser.test.assertEq("grant-permission", msg, "expected message");
+
+ let granted = await new Promise(resolve => {
+ browser.test.withHandlingUserInput(() => {
+ resolve(browser.permissions.request(value));
+ });
+ });
+ browser.test.assertTrue(granted, "permission request succeeded");
+ browser.test.sendMessage("permission-granted");
+ });
+
+ await browser.scripting.registerContentScripts([
+ {
+ id: "script",
+ js: ["script.js"],
+ matches: ["*://example.com/*", "*://example.org/*"],
+ persistAcrossSessions: false,
+ },
+ ]);
+
+ browser.test.sendMessage("background-ready");
+ },
+ files: {
+ "script.js": () => {
+ browser.test.sendMessage("script-ran", window.location.host);
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "http://example.com/",
+ true
+ );
+ let host = await extension.awaitMessage("script-ran");
+ is(host, "example.com", "expected host: example.com");
+ await AppTestDelegate.removeTab(window, tab);
+
+ extension.sendMessage("grant-permission", {
+ origins: ["*://example.org/*"],
+ });
+ await extension.awaitMessage("permission-granted");
+
+ tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://example.org/",
+ true
+ );
+ host = await extension.awaitMessage("script-ran");
+ is(host, "example.org", "expected host: example.org");
+ await AppTestDelegate.removeTab(window, tab);
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html b/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html
new file mode 100644
index 0000000000..41db82eff7
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html
@@ -0,0 +1,580 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for protocol handlers</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+/* eslint-disable mozilla/balanced-listeners */
+
+function protocolChromeScript() {
+ const PROTOCOL_HANDLER_OPEN_PERM_KEY = "open-protocol-handler";
+ const PERMISSION_KEY_DELIMITER = "^";
+
+ /* eslint-env mozilla/chrome-script */
+ addMessageListener("setup", ({ protocol, principalOrigins }) => {
+ let data = {};
+ const protoSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ let protoInfo = protoSvc.getProtocolHandlerInfo(protocol);
+ data.preferredAction = protoInfo.preferredAction == protoInfo.useHelperApp;
+
+ let handlers = protoInfo.possibleApplicationHandlers;
+ data.handlers = handlers.length;
+
+ let handler = handlers.queryElementAt(0, Ci.nsIHandlerApp);
+ data.isWebHandler = handler instanceof Ci.nsIWebHandlerApp;
+ data.uriTemplate = handler.uriTemplate;
+
+ // ext+ protocols should be set as default when there is only one
+ data.preferredApplicationHandler =
+ protoInfo.preferredApplicationHandler == handler;
+ data.alwaysAskBeforeHandling = protoInfo.alwaysAskBeforeHandling;
+ const handlerSvc = Cc[
+ "@mozilla.org/uriloader/handler-service;1"
+ ].getService(Ci.nsIHandlerService);
+ handlerSvc.store(protoInfo);
+
+ for (let origin of principalOrigins) {
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(origin),
+ {}
+ );
+ let pbPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(origin),
+ {
+ privateBrowsingId: 1,
+ }
+ );
+ let permKey =
+ PROTOCOL_HANDLER_OPEN_PERM_KEY + PERMISSION_KEY_DELIMITER + protocol;
+ Services.perms.addFromPrincipal(
+ principal,
+ permKey,
+ Services.perms.ALLOW_ACTION,
+ Services.perms.EXPIRE_NEVER
+ );
+ Services.perms.addFromPrincipal(
+ pbPrincipal,
+ permKey,
+ Services.perms.ALLOW_ACTION,
+ Services.perms.EXPIRE_NEVER
+ );
+ }
+
+ sendAsyncMessage("handlerData", data);
+ });
+ addMessageListener("setPreferredAction", data => {
+ let { protocol, template } = data;
+ const protoSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ let protoInfo = protoSvc.getProtocolHandlerInfo(protocol);
+
+ for (let handler of protoInfo.possibleApplicationHandlers.enumerate()) {
+ if (handler.uriTemplate.startsWith(template)) {
+ protoInfo.preferredApplicationHandler = handler;
+ protoInfo.preferredAction = protoInfo.useHelperApp;
+ protoInfo.alwaysAskBeforeHandling = false;
+ }
+ }
+ const handlerSvc = Cc[
+ "@mozilla.org/uriloader/handler-service;1"
+ ].getService(Ci.nsIHandlerService);
+ handlerSvc.store(protoInfo);
+ sendAsyncMessage("set");
+ });
+}
+
+add_task(async function test_protocolHandler() {
+ let extensionData = {
+ manifest: {
+ protocol_handlers: [
+ {
+ protocol: "ext+foo",
+ name: "a foo protocol handler",
+ uriTemplate: "foo.html?val=%s",
+ },
+ ],
+ },
+
+ background() {
+ browser.test.onMessage.addListener(async (msg, arg) => {
+ if (msg == "open") {
+ let tab = await browser.tabs.create({ url: arg });
+ browser.test.sendMessage("opened", tab.id);
+ } else if (msg == "close") {
+ await browser.tabs.remove(arg);
+ browser.test.sendMessage("closed");
+ }
+ });
+ browser.test.sendMessage("test-url", browser.runtime.getURL("foo.html"));
+ },
+
+ files: {
+ "foo.js": function() {
+ browser.test.sendMessage("test-query", location.search);
+ browser.tabs.getCurrent().then(tab => browser.test.sendMessage("test-tab", tab.id));
+ },
+ "foo.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="foo.js"><\/script>
+ </head>
+ </html>`,
+ },
+ };
+
+ let pb_extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.onMessage.addListener(async (msg, arg) => {
+ if (msg == "open") {
+ let win = await browser.windows.create({ url: arg, incognito: true });
+ browser.test.sendMessage("opened", {
+ windowId: win.id,
+ tabId: win.tabs[0].id,
+ });
+ } else if (msg == "nav") {
+ await browser.tabs.update(arg.tabId, { url: arg.url });
+ browser.test.sendMessage("navigated");
+ } else if (msg == "close") {
+ await browser.windows.remove(arg);
+ browser.test.sendMessage("closed");
+ }
+ });
+ },
+ incognitoOverride: "spanning",
+ });
+ await pb_extension.startup();
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let handlerUrl = await extension.awaitMessage("test-url");
+
+ // Ensure that the protocol handler is configured, and set it as default to
+ // bypass the dialog.
+ let chromeScript = SpecialPowers.loadChromeScript(protocolChromeScript);
+
+ let msg = chromeScript.promiseOneMessage("handlerData");
+ chromeScript.sendAsyncMessage("setup", {
+ protocol: "ext+foo",
+ principalOrigins: [
+ `moz-extension://${extension.uuid}/`,
+ `moz-extension://${pb_extension.uuid}/`,
+ ],
+ });
+ let data = await msg;
+ ok(
+ data.preferredAction,
+ "using a helper application is the preferred action"
+ );
+ ok(data.preferredApplicationHandler, "handler was set as default handler");
+ is(data.handlers, 1, "one handler is set");
+ ok(!data.alwaysAskBeforeHandling, "will not show dialog");
+ ok(data.isWebHandler, "the handler is a web handler");
+ is(data.uriTemplate, `${handlerUrl}?val=%s`, "correct url template");
+ chromeScript.destroy();
+
+ extension.sendMessage("open", "ext+foo:test");
+ let id = await extension.awaitMessage("opened");
+
+ let query = await extension.awaitMessage("test-query");
+ is(query, "?val=ext%2Bfoo%3Atest", "test query ok");
+ is(id, await extension.awaitMessage("test-tab"), "id should match opened tab");
+
+ extension.sendMessage("close", id);
+ await extension.awaitMessage("closed");
+
+ // Test that handling a URL from the commandline works.
+ chromeScript = SpecialPowers.loadChromeScript(() => {
+ /* eslint-env mozilla/chrome-script */
+ let cmdLineHandler = Cc["@mozilla.org/browser/final-clh;1"].getService(
+ Ci.nsICommandLineHandler
+ );
+ let fakeCmdLine = Cu.createCommandLine(
+ ["-url", "ext+foo:cmdline"],
+ null,
+ Ci.nsICommandLine.STATE_REMOTE_EXPLICIT
+ );
+ cmdLineHandler.handle(fakeCmdLine);
+ });
+ query = await extension.awaitMessage("test-query");
+ is(query, "?val=ext%2Bfoo%3Acmdline", "cmdline query ok");
+ id = await extension.awaitMessage("test-tab");
+ extension.sendMessage("close", id);
+ await extension.awaitMessage("closed");
+ chromeScript.destroy();
+
+ // Test the protocol in a private window, watch for the
+ // console error.
+ consoleMonitor.start([{ message: /NS_ERROR_FILE_NOT_FOUND/ }]);
+
+ // Expect the chooser window to be open, close it.
+ chromeScript = SpecialPowers.loadChromeScript(async () => {
+ /* eslint-env mozilla/chrome-script */
+ const CONTENT_HANDLING_URL =
+ "chrome://mozapps/content/handling/appChooser.xhtml";
+ const { BrowserTestUtils } = ChromeUtils.import(
+ "resource://testing-common/BrowserTestUtils.jsm"
+ );
+
+ let windowOpen = BrowserTestUtils.domWindowOpenedAndLoaded();
+
+ sendAsyncMessage("listenWindow");
+
+ let window = await windowOpen;
+ let gBrowser = window.gBrowser;
+ let tabDialogBox = gBrowser.getTabDialogBox(gBrowser.selectedBrowser);
+ let dialogStack = tabDialogBox.getTabDialogManager()._dialogStack;
+
+ let checkFn = dialogEvent =>
+ dialogEvent.detail.dialog?._openedURL == CONTENT_HANDLING_URL;
+
+ let eventPromise = BrowserTestUtils.waitForEvent(
+ dialogStack,
+ "dialogopen",
+ true,
+ checkFn
+ );
+
+ sendAsyncMessage("listenDialog");
+
+ let event = await eventPromise;
+
+ let { dialog } = event.detail;
+
+ let entry = dialog._frame.contentDocument.getElementById("items")
+ .firstChild;
+ sendAsyncMessage("handling", {
+ name: entry.getAttribute("name"),
+ disabled: entry.disabled,
+ });
+
+ dialog.close();
+ });
+
+ // Wait for the chrome script to attach window listener
+ await chromeScript.promiseOneMessage("listenWindow");
+
+ let listenDialog = chromeScript.promiseOneMessage("listenDialog");
+ let windowOpen = pb_extension.awaitMessage("opened");
+
+ pb_extension.sendMessage("open", "ext+foo:test");
+
+ // Wait for chrome script to attach dialog listener
+ await listenDialog;
+ let { tabId, windowId } = await windowOpen;
+
+ let testData = chromeScript.promiseOneMessage("handling");
+ let navPromise = pb_extension.awaitMessage("navigated");
+ pb_extension.sendMessage("nav", { url: "ext+foo:test", tabId });
+ await navPromise;
+ await consoleMonitor.finished();
+ let entry = await testData;
+
+ is(entry.name, "a foo protocol handler", "entry is correct");
+ ok(entry.disabled, "handler is disabled");
+
+ let promiseClosed = pb_extension.awaitMessage("closed");
+ pb_extension.sendMessage("close", windowId);
+ await promiseClosed;
+ await pb_extension.unload();
+
+ // Shutdown the addon, then ensure the protocol was removed.
+ await extension.unload();
+ chromeScript = SpecialPowers.loadChromeScript(() => {
+ /* eslint-env mozilla/chrome-script */
+ addMessageListener("setup", () => {
+ const protoSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ let protoInfo = protoSvc.getProtocolHandlerInfo("ext+foo");
+ sendAsyncMessage(
+ "preferredApplicationHandler",
+ !protoInfo.preferredApplicationHandler
+ );
+ let handlers = protoInfo.possibleApplicationHandlers;
+
+ sendAsyncMessage("handlerData", {
+ preferredApplicationHandler: !protoInfo.preferredApplicationHandler,
+ handlers: handlers.length,
+ });
+ });
+ });
+
+ msg = chromeScript.promiseOneMessage("handlerData");
+ chromeScript.sendAsyncMessage("setup");
+ data = await msg;
+ ok(data.preferredApplicationHandler, "no preferred handler is set");
+ is(data.handlers, 0, "no handler is set");
+ chromeScript.destroy();
+});
+
+add_task(async function test_protocolHandler_two() {
+ let extensionData = {
+ manifest: {
+ protocol_handlers: [
+ {
+ protocol: "ext+foo",
+ name: "a foo protocol handler",
+ uriTemplate: "foo.html?val=%s",
+ },
+ {
+ protocol: "ext+foo",
+ name: "another foo protocol handler",
+ uriTemplate: "foo2.html?val=%s",
+ },
+ ],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ // Ensure that the protocol handler is configured, and set it as default,
+ // but because there are two handlers, the dialog is not bypassed. We
+ // don't test the actual dialog ui, it's been here forever and works based
+ // on the alwaysAskBeforeHandling value.
+ let chromeScript = SpecialPowers.loadChromeScript(protocolChromeScript);
+
+ let msg = chromeScript.promiseOneMessage("handlerData");
+ chromeScript.sendAsyncMessage("setup", {
+ protocol: "ext+foo",
+ principalOrigins: [],
+ });
+ let data = await msg;
+ ok(
+ data.preferredAction,
+ "using a helper application is the preferred action"
+ );
+ ok(data.preferredApplicationHandler, "preferred handler is set");
+ is(data.handlers, 2, "two handlers are set");
+ ok(data.alwaysAskBeforeHandling, "will show dialog");
+ ok(data.isWebHandler, "the handler is a web handler");
+ chromeScript.destroy();
+ await extension.unload();
+});
+
+add_task(async function test_protocolHandler_https_target() {
+ let extensionData = {
+ manifest: {
+ protocol_handlers: [
+ {
+ protocol: "ext+foo",
+ name: "http target",
+ uriTemplate: "https://example.com/foo.html?val=%s",
+ },
+ ],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ ok(true, "https uriTemplate target works");
+ await extension.unload();
+});
+
+add_task(async function test_protocolHandler_http_target() {
+ let extensionData = {
+ manifest: {
+ protocol_handlers: [
+ {
+ protocol: "ext+foo",
+ name: "http target",
+ uriTemplate: "http://example.com/foo.html?val=%s",
+ },
+ ],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ ok(true, "http uriTemplate target works");
+ await extension.unload();
+});
+
+add_task(async function test_protocolHandler_restricted_protocol() {
+ let extensionData = {
+ manifest: {
+ protocol_handlers: [
+ {
+ protocol: "http",
+ name: "take over the http protocol",
+ uriTemplate: "http.html?val=%s",
+ },
+ ],
+ },
+ };
+
+ consoleMonitor.start([
+ { message: /processing protocol_handlers\.0\.protocol/ },
+ ]);
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await Assert.rejects(
+ extension.startup(),
+ /startup failed/,
+ "unable to register restricted handler protocol"
+ );
+
+ await consoleMonitor.finished();
+});
+
+add_task(async function test_protocolHandler_restricted_uriTemplate() {
+ let extensionData = {
+ manifest: {
+ protocol_handlers: [
+ {
+ protocol: "ext+foo",
+ name: "take over the http protocol",
+ uriTemplate: "ftp://example.com/file.txt",
+ },
+ ],
+ },
+ };
+
+ consoleMonitor.start([
+ { message: /processing protocol_handlers\.0\.uriTemplate/ },
+ ]);
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await Assert.rejects(
+ extension.startup(),
+ /startup failed/,
+ "unable to register restricted handler uriTemplate"
+ );
+
+ await consoleMonitor.finished();
+});
+
+add_task(async function test_protocolHandler_duplicate() {
+ let extensionData = {
+ manifest: {
+ protocol_handlers: [
+ {
+ protocol: "ext+foo",
+ name: "foo protocol",
+ uriTemplate: "foo.html?val=%s",
+ },
+ {
+ protocol: "ext+foo",
+ name: "foo protocol",
+ uriTemplate: "foo.html?val=%s",
+ },
+ ],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ // Get the count of handlers installed.
+ let chromeScript = SpecialPowers.loadChromeScript(() => {
+ /* eslint-env mozilla/chrome-script */
+ addMessageListener("setup", () => {
+ const protoSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ let protoInfo = protoSvc.getProtocolHandlerInfo("ext+foo");
+ let handlers = protoInfo.possibleApplicationHandlers;
+ sendAsyncMessage("handlerData", handlers.length);
+ });
+ });
+
+ let msg = chromeScript.promiseOneMessage("handlerData");
+ chromeScript.sendAsyncMessage("setup");
+ let data = await msg;
+ is(data, 1, "cannot re-register the same handler config");
+ chromeScript.destroy();
+ await extension.unload();
+});
+
+// Test that a protocol handler will work if ftp is enabled
+add_task(async function test_ftp_protocolHandler() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Disabling the external protocol permission prompt. We don't need it
+ // for this test.
+ ["security.external_protocol_requires_permission", false],
+ ],
+ });
+ let extensionData = {
+ manifest: {
+ protocol_handlers: [
+ {
+ protocol: "ftp",
+ name: "an ftp protocol handler",
+ uriTemplate: "ftp.html?val=%s",
+ },
+ ],
+ },
+
+ async background() {
+ let url = "ftp://example.com/file.txt";
+ browser.test.onMessage.addListener(async () => {
+ await browser.tabs.create({ url });
+ });
+ },
+
+ files: {
+ "ftp.js": function() {
+ browser.test.sendMessage("test-query", location.search);
+ },
+ "ftp.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="ftp.js"><\/script>
+ </head>
+ </html>`,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ const handlerUrl = `moz-extension://${extension.uuid}/ftp.html`;
+
+ let chromeScript = SpecialPowers.loadChromeScript(protocolChromeScript);
+
+ // Set the preferredAction to this extension as ftp will default to system. If
+ // we didn't bypass the dialog for this test, the user would get asked in this case.
+ let msg = chromeScript.promiseOneMessage("set");
+ chromeScript.sendAsyncMessage("setPreferredAction", {
+ protocol: "ftp",
+ template: handlerUrl,
+ });
+ await msg;
+
+ msg = chromeScript.promiseOneMessage("handlerData");
+ chromeScript.sendAsyncMessage("setup", { protocol: "ftp", principalOrigins: [] });
+ let data = await msg;
+ ok(
+ data.preferredAction,
+ "using a helper application is the preferred action"
+ );
+ ok(data.preferredApplicationHandler, "handler was set as default handler");
+ is(data.handlers, 1, "one handler is set");
+ ok(!data.alwaysAskBeforeHandling, "will not show dialog");
+ ok(data.isWebHandler, "the handler is a web handler");
+ is(data.uriTemplate, `${handlerUrl}?val=%s`, "correct url template");
+
+ chromeScript.destroy();
+
+ extension.sendMessage("run");
+ let query = await extension.awaitMessage("test-query");
+ is(query, "?val=ftp%3A%2F%2Fexample.com%2Ffile.txt", "test query ok");
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html b/toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html
new file mode 100644
index 0000000000..18ff14a6de
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html
@@ -0,0 +1,92 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script>
+"use strict";
+
+function getExtension() {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "redirect-to-jar@mochi.test",
+ },
+ },
+ "permissions": [
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ ],
+ "web_accessible_resources": [
+ "finished.html",
+ ],
+ },
+ useAddonManager: "temporary",
+ files: {
+ "finished.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <h1>redirected!</h1>
+ </body>
+ </html>
+ `,
+ },
+ background: async () => {
+ let redirectUrl = browser.runtime.getURL("finished.html");
+ browser.webRequest.onBeforeRequest.addListener(details => {
+ return {redirectUrl};
+ }, {urls: ["*://*/intercept*"]}, ["blocking"]);
+
+ let code = `new Promise(resolve => {
+ var s = document.createElement('iframe');
+ s.src = "/intercept?r=" + Math.random();
+ s.onload = async () => {
+ let url = await window.wrappedJSObject.SpecialPowers.spawn(s, [], () => content.location.href );
+ resolve(['loaded', url]);
+ }
+ s.onerror = () => resolve(['error']);
+ document.documentElement.appendChild(s);
+ });`;
+
+ async function testSubFrameResource(tabId, code) {
+ let [result] = await browser.tabs.executeScript(tabId, { code });
+ return result;
+ }
+
+ let tab = await browser.tabs.create({url: "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_sample.html"});
+ let result = await testSubFrameResource(tab.id, code);
+ browser.test.assertEq("loaded", result[0], "frame 1 loaded");
+ browser.test.assertEq(redirectUrl, result[1], "frame 1 redirected");
+ // If jar caching breaks redirects, this next test will fail (See Bug 1390346).
+ result = await testSubFrameResource(tab.id, code);
+ browser.test.assertEq("loaded", result[0], "frame 2 loaded");
+ browser.test.assertEq(redirectUrl, result[1], "frame 2 redirected");
+ await browser.tabs.remove(tab.id);
+ browser.test.sendMessage("requestsCompleted");
+ },
+ });
+}
+
+add_task(async function test_redirect_to_jar() {
+ let extension = getExtension();
+ await extension.startup();
+ await extension.awaitMessage("requestsCompleted");
+ await extension.unload();
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html b/toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html
new file mode 100644
index 0000000000..a5c7024a83
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html
@@ -0,0 +1,130 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for WebRequest urlClassification</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.trackingprotection.enabled", true]],
+ });
+
+ let chromeScript = SpecialPowers.loadChromeScript(async _ => {
+ /* eslint-env mozilla/chrome-script */
+ const {UrlClassifierTestUtils} = ChromeUtils.import("resource://testing-common/UrlClassifierTestUtils.jsm");
+ await UrlClassifierTestUtils.addTestTrackers();
+ sendAsyncMessage("trackersLoaded");
+ });
+ await chromeScript.promiseOneMessage("trackersLoaded");
+ chromeScript.destroy();
+});
+
+add_task(async function test_urlClassification() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_first", false]],
+ });
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "proxy", "<all_urls>"],
+ },
+ background() {
+ let expected = {
+ "http://tracking.example.org/": {first: "tracking", thirdParty: false, },
+ "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=tracking.example.org": { thirdParty: false, },
+ "http://tracking.example.org/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png": {third: "tracking", thirdParty: true, },
+ "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=example.net": { thirdParty: false, },
+ "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png": { thirdParty: true, },
+ };
+ function testRequest(details) {
+ let expect = expected[details.url];
+ if (expect) {
+ if (expect.first) {
+ browser.test.assertTrue(details.urlClassification.firstParty.includes("tracking"), "tracking firstParty");
+ } else {
+ browser.test.assertEq(details.urlClassification.firstParty.length, 0, "not tracking firstParty");
+ }
+ if (expect.third) {
+ browser.test.assertTrue(details.urlClassification.thirdParty.includes("tracking"), "tracking thirdParty");
+ } else {
+ browser.test.assertEq(details.urlClassification.thirdParty.length, 0, "not tracking thirdParty");
+ }
+
+ browser.test.assertEq(details.thirdParty, expect.thirdParty, "3rd party flag matches");
+ return true;
+ }
+ return false;
+ }
+
+ browser.proxy.onRequest.addListener(details => {
+ browser.test.log(`proxy.onRequest ${JSON.stringify(details)}`);
+ testRequest(details);
+ }, {urls: ["http://mochi.test/tests/*", "http://tracking.example.org/*", "http://example.net/*"]});
+ browser.webRequest.onBeforeRequest.addListener(async (details) => {
+ browser.test.log(`webRequest.onBeforeRequest ${JSON.stringify(details)}`);
+ testRequest(details);
+ }, {urls: ["http://mochi.test/tests/*", "http://tracking.example.org/*", "http://example.net/*"]}, ["blocking"]);
+ browser.webRequest.onCompleted.addListener(async (details) => {
+ browser.test.log(`webRequest.onCompleted ${JSON.stringify(details)}`);
+ if (testRequest(details)) {
+ browser.test.sendMessage("classification", details.url);
+ }
+ }, {urls: ["http://mochi.test/tests/*", "http://tracking.example.org/*", "http://example.net/*"]});
+ },
+ });
+ await extension.startup();
+
+ // Test first party tracking classification.
+ let url = "http://tracking.example.org/";
+ let win = window.open(url);
+ is(await extension.awaitMessage("classification"), url, "request completed");
+ win.close();
+
+ // Test third party tracking classification, expecting two results.
+ url = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=tracking.example.org";
+ win = window.open(url);
+ is(await extension.awaitMessage("classification"), url);
+ is(await extension.awaitMessage("classification"),
+ "http://tracking.example.org/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png",
+ "request completed");
+ win.close();
+
+ // Test third party tracking classification, expecting two results.
+ url = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=example.net";
+ win = window.open(url);
+ is(await extension.awaitMessage("classification"), url);
+ is(await extension.awaitMessage("classification"),
+ "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png",
+ "request completed");
+ win.close();
+
+ await extension.unload();
+});
+
+add_task(async function teardown() {
+ let chromeScript = SpecialPowers.loadChromeScript(async _ => {
+ /* eslint-env mozilla/chrome-script */
+ // Cleanup cache
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, value => resolve());
+ });
+
+ const {UrlClassifierTestUtils} = ChromeUtils.import("resource://testing-common/UrlClassifierTestUtils.jsm");
+ await UrlClassifierTestUtils.cleanupTestTrackers();
+ sendAsyncMessage("trackersUnloaded");
+ });
+ await chromeScript.promiseOneMessage("trackersUnloaded");
+ chromeScript.destroy();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html
new file mode 100644
index 0000000000..85f98d5034
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html
@@ -0,0 +1,83 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function background() {
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertEq(port.name, "ernie", "port name correct");
+ browser.test.assertTrue(port.sender.url.endsWith("file_sample.html"), "URL correct");
+ browser.test.assertTrue(port.sender.tab.url.endsWith("file_sample.html"), "tab URL correct");
+ browser.test.assertEq(port.sender.frameId, 0, "frameId of top frame");
+
+ let expected = "message 1";
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, expected, "message is expected");
+ if (expected == "message 1") {
+ port.postMessage("message 2");
+ expected = "message 3";
+ } else if (expected == "message 3") {
+ expected = "disconnect";
+ browser.test.notifyPass("runtime.connect");
+ }
+ });
+ port.onDisconnect.addListener(() => {
+ browser.test.assertEq(null, port.error, "No error because port is closed by disconnect() at other end");
+ browser.test.assertEq(expected, "disconnect", "got disconnection at right time");
+ });
+ });
+}
+
+function contentScript() {
+ let port = browser.runtime.connect({name: "ernie"});
+ port.postMessage("message 1");
+ port.onMessage.addListener(msg => {
+ if (msg == "message 2") {
+ port.postMessage("message 3");
+ port.disconnect();
+ }
+ });
+}
+
+let extensionData = {
+ background,
+ manifest: {
+ "permissions": ["tabs"],
+ "content_scripts": [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ }],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+};
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+
+ await Promise.all([waitForLoad(win), extension.awaitFinish("runtime.connect")]);
+
+ win.close();
+
+ await extension.unload();
+ info("extension unloaded");
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html
new file mode 100644
index 0000000000..13b9029c48
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html
@@ -0,0 +1,102 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function backgroundScript(token) {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, "done");
+ browser.test.notifyPass("sendmessage_reply");
+ });
+
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertTrue(port.sender.url.endsWith("file_sample.html"), "sender url correct");
+ browser.test.assertTrue(port.sender.tab.url.endsWith("file_sample.html"), "sender url correct");
+
+ let tabId = port.sender.tab.id;
+ browser.tabs.connect(tabId, {name: token});
+
+ browser.test.assertEq(port.name, token, "token matches");
+ port.postMessage(token + "-done");
+ });
+
+ browser.test.sendMessage("background-ready");
+}
+
+function contentScript(token) {
+ let gotTabMessage = false;
+ let badTabMessage = false;
+ browser.runtime.onConnect.addListener(port => {
+ if (port.name == token) {
+ gotTabMessage = true;
+ } else {
+ badTabMessage = true;
+ }
+ port.disconnect();
+ });
+
+ let port = browser.runtime.connect(null, {name: token});
+ port.onMessage.addListener(function(msg) {
+ if (msg != token + "-done" || !gotTabMessage || badTabMessage) {
+ return; // test failed
+ }
+
+ // FIXME: Removing this line causes the test to fail:
+ // resource://gre/modules/ExtensionUtils.jsm, line 651: NS_ERROR_NOT_INITIALIZED
+ port.disconnect();
+ browser.runtime.sendMessage("done");
+ });
+}
+
+function makeExtension() {
+ let token = Math.random();
+ let extensionData = {
+ background: `(${backgroundScript})("${token}")`,
+ manifest: {
+ "permissions": ["tabs"],
+ "content_scripts": [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_idle",
+ }],
+ },
+
+ files: {
+ "content_script.js": `(${contentScript})("${token}")`,
+ },
+ };
+ return extensionData;
+}
+
+add_task(async function test_contentscript() {
+ let extension1 = ExtensionTestUtils.loadExtension(makeExtension());
+ let extension2 = ExtensionTestUtils.loadExtension(makeExtension());
+ await Promise.all([extension1.startup(), extension2.startup()]);
+
+ await extension1.awaitMessage("background-ready");
+ await extension2.awaitMessage("background-ready");
+
+ let win = window.open("file_sample.html");
+
+ await Promise.all([waitForLoad(win),
+ extension1.awaitFinish("sendmessage_reply"),
+ extension2.awaitFinish("sendmessage_reply")]);
+
+ win.close();
+
+ await extension1.unload();
+ await extension2.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_iframe.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_iframe.html
new file mode 100644
index 0000000000..9c64635063
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_iframe.html
@@ -0,0 +1,136 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// The purpose of this test is to verify that the port.sender properties are
+// not set for messages from iframes in background scripts. This is the toolkit
+// version of the browser_ext_contentscript_nontab_connect.js test, and exists
+// to provide test coverage for non-toolkit builds (e.g. Android).
+//
+// This used to be a xpcshell test (from bug 1488105), but became a mochitest
+// because port.sender.tab and port.sender.frameId do not represent the real
+// values in xpcshell tests.
+// Specifically, ProxyMessenger.prototype.getSender uses the tabTracker, which
+// expects real tabs instead of browsers from the ContentPage API in xpcshell
+// tests.
+add_task(async function connect_from_background_frame() {
+ if (!SpecialPowers.getBoolPref("extensions.webextensions.remote", true)) {
+ info("Cannot load remote content in parent process; skipping test task");
+ return;
+ }
+ async function background() {
+ const FRAME_URL = "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html";
+ browser.runtime.onConnect.addListener(port => {
+ // The next two assertions are the reason for this being a mochitest
+ // instead of a xpcshell test.
+ browser.test.assertEq(port.sender.tab, undefined, "Sender is not a tab");
+ browser.test.assertEq(port.sender.frameId, undefined, "frameId unset");
+ browser.test.assertEq(port.sender.url, FRAME_URL, "Expected sender URL");
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq("pong", msg, "Reply from content script");
+ port.disconnect();
+ });
+ port.postMessage("ping");
+ });
+
+ await browser.contentScripts.register({
+ matches: [FRAME_URL],
+ js: [{ file: "contentscript.js" }],
+ allFrames: true,
+ });
+
+ let f = document.createElement("iframe");
+ f.src = FRAME_URL;
+ document.body.appendChild(f);
+ }
+
+ function contentScript() {
+ browser.test.log(`Running content script at ${document.URL}`);
+
+ let port = browser.runtime.connect();
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq("ping", msg, "Expected message to content script");
+ port.postMessage("pong");
+ });
+ port.onDisconnect.addListener(() => {
+ browser.test.sendMessage("disconnected_in_content_script");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["https://example.com/*"],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ },
+ background,
+ });
+ await extension.startup();
+ await extension.awaitMessage("disconnected_in_content_script");
+ await extension.unload();
+});
+
+// The test_ext_contentscript_fission_frame.html test already checks the
+// behavior of onConnect in cross-origin frames, so here we just limit the test
+// to checking that the port.sender properties are sensible.
+add_task(async function connect_from_content_script_in_frame() {
+ async function background() {
+ const TAB_URL = "https://example.org/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html";
+ const FRAME_URL = "https://example.org/tests/toolkit/components/extensions/test/mochitest/file_contains_img.html";
+ let createdTab;
+ browser.runtime.onConnect.addListener(port => {
+ // The next two assertions are the reason for this being a mochitest
+ // instead of a xpcshell test.
+ browser.test.assertEq(port.sender.tab.url, TAB_URL, "Sender is the tab");
+ browser.test.assertTrue(port.sender.frameId > 0, "frameId is set");
+ browser.test.assertEq(port.sender.url, FRAME_URL, "Expected sender URL");
+
+ browser.test.assertEq(createdTab.id, port.sender.tab.id, "Tab to close");
+ browser.tabs.remove(port.sender.tab.id).then(() => {
+ browser.test.sendMessage("tab_port_checked_and_tab_closed");
+ });
+ });
+
+ await browser.contentScripts.register({
+ matches: [FRAME_URL],
+ js: [{ file: "contentscript.js" }],
+ allFrames: true,
+ });
+
+ createdTab = await browser.tabs.create({ url: TAB_URL });
+ }
+
+ function contentScript() {
+ browser.test.log(`Running content script at ${document.URL}`);
+
+ browser.runtime.connect();
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["https://example.org/*"],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ },
+ background,
+ });
+ await extension.startup();
+ await extension.awaitMessage("tab_port_checked_and_tab_closed");
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html
new file mode 100644
index 0000000000..b671cba23d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html
@@ -0,0 +1,126 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script>
+"use strict";
+
+add_task(async function test_connect_bidirectionally_and_postMessage() {
+ function background() {
+ let onConnectCount = 0;
+ browser.runtime.onConnect.addListener(port => {
+ // 3. onConnect by connect() from CS.
+ browser.test.assertEq("from-cs", port.name);
+ browser.test.assertEq(1, ++onConnectCount,
+ "BG onConnect should be called once");
+
+ let tabId = port.sender.tab.id;
+ browser.test.assertTrue(tabId, "content script must have a tab ID");
+
+ let port2;
+ let postMessageCount1 = 0;
+ port.onMessage.addListener(msg => {
+ // 11. port.onMessage by port.postMessage in CS.
+ browser.test.assertEq("from CS to port", msg);
+ browser.test.assertEq(1, ++postMessageCount1,
+ "BG port.onMessage should be called once");
+
+ // 12. should trigger port2.onMessage in CS.
+ port2.postMessage("from BG to port2");
+ });
+
+ // 4. Should trigger onConnect in CS.
+ port2 = browser.tabs.connect(tabId, {name: "from-bg"});
+ let postMessageCount2 = 0;
+ port2.onMessage.addListener(msg => {
+ // 7. onMessage by port2.postMessage in CS.
+ browser.test.assertEq("from CS to port2", msg);
+ browser.test.assertEq(1, ++postMessageCount2,
+ "BG port2.onMessage should be called once");
+
+ // 8. Should trigger port.onMessage in CS.
+ port.postMessage("from BG to port");
+ });
+ });
+
+ // 1. Notify test runner to create a new tab.
+ browser.test.sendMessage("ready");
+ }
+
+ function contentScript() {
+ let onConnectCount = 0;
+ let port;
+ browser.runtime.onConnect.addListener(port2 => {
+ // 5. onConnect by connect() from BG.
+ browser.test.assertEq("from-bg", port2.name);
+ browser.test.assertEq(1, ++onConnectCount,
+ "CS onConnect should be called once");
+
+ let postMessageCount2 = 0;
+ port2.onMessage.addListener(msg => {
+ // 12. port2.onMessage by port2.postMessage in BG.
+ browser.test.assertEq("from BG to port2", msg);
+ browser.test.assertEq(1, ++postMessageCount2,
+ "CS port2.onMessage should be called once");
+
+ // TODO(robwu): Do not explicitly disconnect, it should not be a problem
+ // if we keep the ports open. However, not closing the ports causes the
+ // test to fail with NS_ERROR_NOT_INITIALIZED in ExtensionUtils.jsm, in
+ // Port.prototype.disconnect (nsIMessageSender.sendAsyncMessage).
+ port.disconnect();
+ port2.disconnect();
+ browser.test.notifyPass("ping pong done");
+ });
+ // 6. should trigger port2.onMessage in BG.
+ port2.postMessage("from CS to port2");
+ });
+
+ // 2. should trigger onConnect in BG.
+ port = browser.runtime.connect({name: "from-cs"});
+ let postMessageCount1 = 0;
+ port.onMessage.addListener(msg => {
+ // 9. onMessage by port.postMessage in BG.
+ browser.test.assertEq("from BG to port", msg);
+ browser.test.assertEq(1, ++postMessageCount1,
+ "CS port.onMessage should be called once");
+
+ // 10. should trigger port.onMessage in BG.
+ port.postMessage("from CS to port");
+ });
+ }
+
+ let extensionData = {
+ background,
+ manifest: {
+ content_scripts: [{
+ js: ["contentscript.js"],
+ matches: ["http://mochi.test/*/file_sample.html"],
+ }],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ info("extension loaded");
+
+ await extension.awaitMessage("ready");
+
+ let win = window.open("file_sample.html");
+ await extension.awaitFinish("ping pong done");
+ win.close();
+
+ await extension.unload();
+ info("extension unloaded");
+});
+</script>
+</body>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html
new file mode 100644
index 0000000000..f18190bf8b
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html
@@ -0,0 +1,77 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function background() {
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertEq(port.name, "ernie", "port name correct");
+ port.onDisconnect.addListener(() => {
+ browser.test.assertEq(null, port.error, "The port is implicitly closed without errors when the other context unloads");
+ // Closing an already-disconnected port is a no-op.
+ port.disconnect();
+ port.disconnect();
+ browser.test.sendMessage("disconnected");
+ });
+ browser.test.sendMessage("connected");
+ });
+}
+
+function contentScript() {
+ browser.runtime.connect({name: "ernie"});
+}
+
+let extensionData = {
+ background,
+ manifest: {
+ "permissions": ["tabs"],
+ "content_scripts": [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_idle",
+ }],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+};
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+ await Promise.all([waitForLoad(win), extension.awaitMessage("connected")]);
+ win.close();
+ await extension.awaitMessage("disconnected");
+
+ info("win.close() succeeded");
+
+ win = window.open("file_sample.html");
+ await Promise.all([waitForLoad(win), extension.awaitMessage("connected")]);
+
+ // Add an "unload" listener so that we don't put the window in the
+ // bfcache. This way it gets destroyed immediately upon navigation.
+ win.addEventListener("unload", function() {}); // eslint-disable-line mozilla/balanced-listeners
+
+ win.location = "http://example.com";
+ await extension.awaitMessage("disconnected");
+ win.close();
+
+ await extension.unload();
+ info("extension unloaded");
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_script_filenames.html b/toolkit/components/extensions/test/mochitest/test_ext_script_filenames.html
new file mode 100644
index 0000000000..de0993c33d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_script_filenames.html
@@ -0,0 +1,62 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Script Filenames Test</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_tabs_executeScript() {
+ let validFileName = "script.js";
+ let invalidFileName = "script.xyz";
+
+ async function background() {
+ await browser.tabs.executeScript({ file: "script.js" });
+
+ await browser.test.assertRejects(
+ browser.tabs.executeScript({ file: "script.xyz" }),
+ Error,
+ "invalid filename does not execute"
+ );
+ browser.test.notifyPass("execute-script");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["<all_urls>"],
+ },
+
+ background,
+
+ files: {
+ [validFileName]: function contentScript1() {
+ browser.test.sendMessage("content-script-loaded");
+ },
+ [invalidFileName]: function contentScript2() {
+ browser.test.fail("this script should not be loaded");
+ },
+ },
+ });
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "http://mochi.test:8888/",
+ true
+ );
+ await extension.startup();
+
+ await extension.awaitMessage("content-script-loaded");
+ await extension.awaitFinish("execute-script");
+
+ await extension.unload();
+ await AppTestDelegate.removeTab(window, tab);
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_contentScripts.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_contentScripts.html
new file mode 100644
index 0000000000..c8457b6d36
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_contentScripts.html
@@ -0,0 +1,1532 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Tests scripting.*ContentScripts()</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+
+"use strict";
+
+const MOCHITEST_HOST_PERMISSIONS = [
+ "*://mochi.test/",
+ "*://mochi.xorigin-test/",
+ "*://test1.example.com/",
+];
+
+const makeExtension = ({ manifest: manifestProps, ...otherProps }) => {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["scripting"],
+ host_permissions: [
+ ...MOCHITEST_HOST_PERMISSIONS,
+ // Used in `file_contains_iframe.html`
+ "*://example.org/",
+ ],
+ granted_host_permissions: true,
+ ...manifestProps,
+ },
+ useAddonManager: "temporary",
+ ...otherProps,
+ });
+};
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", true]],
+ });
+});
+
+add_task(async function test_validate_registerContentScripts_params() {
+ let extension = makeExtension({
+ async background() {
+ const TEST_CASES = [
+ {
+ title: "no js and no css",
+ params: [
+ {
+ id: "script",
+ matches: ["*://mochi.test/*"],
+ persistAcrossSessions: false,
+ },
+ ],
+ expectedError: "At least one js or css must be specified.",
+ },
+ {
+ title: "empty js",
+ params: [
+ {
+ id: "script",
+ js: [],
+ matches: ["*://mochi.test/*"],
+ persistAcrossSessions: false,
+ },
+ ],
+ expectedError: "At least one js or css must be specified.",
+ },
+ {
+ title: "empty css",
+ params: [
+ {
+ id: "script",
+ css: [],
+ matches: ["*://mochi.test/*"],
+ persistAcrossSessions: false,
+ },
+ ],
+ expectedError: "At least one js or css must be specified.",
+ },
+ {
+ title: "no matches",
+ params: [
+ {
+ id: "script",
+ js: ["script.js"],
+ persistAcrossSessions: false,
+ },
+ ],
+ expectedError: "matches must be specified.",
+ },
+ {
+ title: "empty matches",
+ params: [
+ {
+ id: "script",
+ js: ["script.js"],
+ matches: [],
+ persistAcrossSessions: false,
+ },
+ ],
+ expectedError: "matches must be specified.",
+ },
+ {
+ title: "one empty match",
+ params: [
+ {
+ id: "script",
+ js: ["script.js"],
+ matches: [""],
+ persistAcrossSessions: false,
+ },
+ ],
+ expectedError: "Invalid url pattern: ",
+ },
+ {
+ title: "invalid match",
+ params: [
+ {
+ id: "script",
+ js: ["script.js"],
+ matches: ["not-a-pattern"],
+ persistAcrossSessions: false,
+ },
+ ],
+ expectedError: "Invalid url pattern: not-a-pattern",
+ },
+ {
+ title: "invalid match and valid match",
+ params: [
+ {
+ id: "script",
+ js: ["script.js"],
+ matches: ["*://mochi.test/*", "not-a-pattern"],
+ persistAcrossSessions: false,
+ },
+ ],
+ expectedError: "Invalid url pattern: not-a-pattern",
+ },
+ {
+ title: "one empty value in excludeMatches",
+ params: [
+ {
+ id: "script",
+ js: ["script.js"],
+ matches: ["*://mochi.test/*"],
+ excludeMatches: [""],
+ persistAcrossSessions: false,
+ },
+ ],
+ expectedError: "Invalid url pattern: ",
+ },
+ {
+ title: "invalid value in excludeMatches",
+ params: [
+ {
+ id: "script",
+ js: ["script.js"],
+ matches: ["*://mochi.test/*"],
+ excludeMatches: ["not-a-pattern"],
+ persistAcrossSessions: false,
+ },
+ ],
+ expectedError: "Invalid url pattern: not-a-pattern",
+ },
+ {
+ title: "duplicate IDs",
+ params: [
+ {
+ id: "script-1",
+ js: ["script.js"],
+ matches: ["*://mochi.test/*"],
+ persistAcrossSessions: false,
+ },
+ {
+ id: "script-1",
+ js: ["script.js"],
+ matches: ["*://mochi.test/*"],
+ persistAcrossSessions: false,
+ },
+ ],
+ expectedError: `Script ID "script-1" found more than once in 'scripts' array.`,
+ },
+ {
+ title: "empty id",
+ params: [
+ {
+ id: "",
+ js: ["script.js"],
+ matches: ["*://mochi.test/*"],
+ persistAcrossSessions: false,
+ },
+ ],
+ expectedError: "Invalid content script id.",
+ },
+ {
+ title: "id starting with _",
+ params: [
+ {
+ id: "_foo",
+ js: ["script.js"],
+ matches: ["*://mochi.test/*"],
+ persistAcrossSessions: false,
+ },
+ ],
+ expectedError: "Invalid content script id.",
+ },
+ ];
+
+ for (const { title, params, expectedError } of TEST_CASES) {
+ await browser.test.assertRejects(
+ browser.scripting.registerContentScripts(params),
+ expectedError,
+ `${title} - got expected error`
+ );
+ }
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(0, scripts.length, "expected no registered script");
+
+ browser.test.notifyPass("test-finished");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("test-finished");
+ await extension.unload();
+});
+
+add_task(async function test_registerContentScripts_with_already_registered_id() {
+ let extension = makeExtension({
+ async background() {
+ const script = {
+ id: "script-1",
+ js: ["script.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ };
+
+ await browser.scripting.registerContentScripts([script]);
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+
+ await browser.test.assertRejects(
+ browser.scripting.registerContentScripts([script]),
+ `Content script with id "${script.id}" is already registered.`,
+ "got expected error"
+ );
+
+ scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+
+ browser.test.notifyPass("test-finished");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("test-finished");
+ await extension.unload();
+});
+
+add_task(async function test_validate_getRegisteredContentScripts_params() {
+ let extension = makeExtension({
+ async background() {
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(0, scripts.length, "expected no registered scripts");
+
+ scripts = await browser.scripting.getRegisteredContentScripts({
+ ids: ["non-existent-id"]
+ });
+ browser.test.assertEq(0, scripts.length, "expected no registered scripts");
+
+ browser.test.log("test call with undefined filter and a chrome-compatible callback");
+ scripts = await new Promise(resolve => {
+ browser.scripting.getRegisteredContentScripts(undefined, resolve);
+ });
+ browser.test.assertEq(0, scripts.length, "expected no registered scripts");
+
+ browser.test.log("test call with only the chrome-compatible callback");
+ scripts = await new Promise(resolve => {
+ browser.scripting.getRegisteredContentScripts(resolve);
+ });
+ browser.test.assertEq(0, scripts.length, "expected no registered scripts");
+
+ browser.test.notifyPass("test-finished");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("test-finished");
+ await extension.unload();
+});
+
+add_task(async function test_getRegisteredContentScripts() {
+ let extension = makeExtension({
+ async background() {
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(0, scripts.length, "expected no registered scripts");
+
+ const aScript = {
+ id: "a-script",
+ js: ["script.js"],
+ matches: ["<all_urls>"],
+ persistAcrossSessions: false,
+ };
+
+ await browser.scripting.registerContentScripts([aScript]);
+
+ scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+ browser.test.assertEq(aScript.id, scripts[0].id, "expected correct id");
+
+ // This should return no registered scripts.
+ scripts = await browser.scripting.getRegisteredContentScripts({ ids: [] });
+ browser.test.assertEq(0, scripts.length, "expected 0 registered script");
+
+ // Verify that invalid IDs are omitted but valid IDs are used to return
+ // registered scripts.
+ scripts = await browser.scripting.getRegisteredContentScripts({
+ ids: ["non-existent-id", aScript.id]
+ });
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+ browser.test.assertEq(aScript.id, scripts[0].id, "expected correct id");
+
+ browser.test.notifyPass("test-finished");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("test-finished");
+ await extension.unload();
+});
+
+add_task(async function test_registerContentScripts_js() {
+ let extension = makeExtension({
+ async background() {
+ const TEST_CASES = [
+ // This should have no effect but it should not throw.
+ {
+ title: "no script",
+ params: [],
+ },
+ {
+ title: "one script",
+ params: [
+ {
+ id: "script-1",
+ js: ["script-1.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ }
+ ],
+ },
+ {
+ title: "one script in all frames",
+ params: [
+ {
+ id: "script-2",
+ js: ["script-2.js"],
+ matches: [
+ "*://test1.example.com/*",
+ "*://example.org/*",
+ ],
+ allFrames: true,
+ persistAcrossSessions: false,
+ }
+ ],
+ },
+ {
+ title: "one script in all frames with excludeMatches set",
+ params: [
+ {
+ id: "script-3",
+ js: ["script-3.js"],
+ matches: [
+ "*://test1.example.com/*",
+ "*://example.org/*",
+ ],
+ allFrames: true,
+ excludeMatches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ }
+ ],
+ },
+ {
+ title: "one script, two js paths",
+ params: [
+ {
+ id: "script-4",
+ js: ["script-4-1.js", "script-4-2.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ }
+ ],
+ },
+ {
+ title: "empty excludeMatches",
+ params: [
+ {
+ id: "script-5",
+ // This path should be normalized.
+ js: ["/script-5.js"],
+ matches: ["*://test1.example.com/*"],
+ excludeMatches: [],
+ persistAcrossSessions: false,
+ }
+ ],
+ },
+ ];
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(0, scripts.length, "expected no registered script");
+
+ for (const { title, params } of TEST_CASES) {
+ const res = await browser.scripting.registerContentScripts(params);
+ browser.test.assertEq(
+ undefined,
+ res,
+ `${title} - expected no result`
+ );
+
+ const script = await browser.scripting.getRegisteredContentScripts({
+ ids: params.map(param => param.id)
+ });
+ browser.test.assertEq(
+ params.length,
+ script.length,
+ `${title} - got the expected number of registered scripts`
+ );
+ }
+
+ scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(
+ // A test case declared above does not contain any script to register.
+ TEST_CASES.length - 1,
+ scripts.length,
+ "got the expected number of registered scripts"
+ );
+ browser.test.assertEq(
+ JSON.stringify([
+ {
+ id: "script-1",
+ allFrames: false,
+ matches: ["*://test1.example.com/*"],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ js: ["script-1.js"],
+ },
+ {
+ id: "script-2",
+ allFrames: true,
+ matches: [
+ "*://test1.example.com/*",
+ "*://example.org/*",
+ ],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ js: ["script-2.js"],
+ },
+ {
+ id: "script-3",
+ allFrames: true,
+ matches: [
+ "*://test1.example.com/*",
+ "*://example.org/*",
+ ],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ excludeMatches: ["*://test1.example.com/*"],
+ js: ["script-3.js"],
+ },
+ {
+ id: "script-4",
+ allFrames: false,
+ matches: ["*://test1.example.com/*"],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ js: ["script-4-1.js", "script-4-2.js"],
+ },
+ {
+ id: "script-5",
+ allFrames: false,
+ matches: ["*://test1.example.com/*"],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ js: ["script-5.js"],
+ },
+ ]),
+ JSON.stringify(scripts),
+ "got expected scripts"
+ );
+
+ browser.test.sendMessage("background-ready");
+ },
+ files: {
+ "script-1.js": () => {
+ browser.test.sendMessage(
+ "script-ran",
+ { file: "script-1.js", value: document.title }
+ );
+ },
+ "script-2.js": () => {
+ browser.test.sendMessage(
+ "script-ran",
+ { file: "script-2.js", value: document.title }
+ );
+ },
+ "script-3.js": () => {
+ browser.test.sendMessage(
+ "script-ran",
+ { file: "script-3.js", value: document.title }
+ );
+ },
+ "script-4-1.js": () => {
+ // We inject this script (first) as well as the one defined right
+ // after. The order should be respected, which is why we define a
+ // property here and check it in the second script.
+ window.SCRIPT_4_INJECTED = "SCRIPT_4_INJECTED";
+ },
+ "script-4-2.js": () => {
+ browser.test.sendMessage(
+ "script-ran",
+ { file: "script-4-2.js", value: window.SCRIPT_4_INJECTED }
+ );
+ delete window.SCRIPT_4_INJECTED;
+ },
+ "script-5.js": () => {
+ browser.test.sendMessage(
+ "script-ran",
+ { file: "script-5.js", value: document.title }
+ );
+ },
+ },
+ });
+
+ let scriptsRan = 0;
+ let results = [];
+ let completePromise = new Promise(resolve => {
+ extension.onMessage("script-ran", result => {
+ results.push(result);
+ scriptsRan++;
+
+ // The value below should be updated when TEST_CASES above is changed.
+ if (scriptsRan === 6) {
+ resolve();
+ }
+ });
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ // Load a page that will trigger the content scripts previously registered.
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html",
+ true
+ );
+
+ // Wait for all content scripts to be executed.
+ await completePromise;
+
+ // Verify that the scripts have been executed correctly. We sort the results
+ // to compare them against expected values.
+ results.sort((a, b) => {
+ return a.file.localeCompare(b.file) || a.value.localeCompare(b.value);
+ });
+ ok(
+ JSON.stringify([
+ { file: "script-1.js", value: "file contains iframe" },
+ // script-2.js should be injected in two frames
+ { file: "script-2.js", value: "file contains iframe" },
+ { file: "script-2.js", value: "file contains img" },
+ { file: "script-3.js", value: "file contains img" },
+ // script-4-1.js will add a prop to the `window` object, which should be
+ // read by `script-4-2.js`.
+ { file: "script-4-2.js", value: "SCRIPT_4_INJECTED" },
+ { file: "script-5.js", value: "file contains iframe" },
+ ]) === JSON.stringify(results),
+ "got expected script results" + JSON.stringify(results)
+ );
+
+ await AppTestDelegate.removeTab(window, tab);
+ await extension.unload();
+});
+
+add_task(async function test_registerContentScripts_are_not_unregistered() {
+ let extension = makeExtension({
+ files: {
+ "background.html": `<!DOCTYPE html>
+ <html>
+ <head><meta charset="utf-8"></head>
+ <body>
+ <script src="background.js"><\/script>
+ </body>
+ </html>
+ `,
+ "background.js": async () => {
+ await browser.scripting.registerContentScripts([
+ {
+ id: "a-script",
+ js: ["script.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ },
+ ]);
+
+ browser.test.sendMessage("background-executed");
+ },
+ "script.js": () => {
+ browser.test.sendMessage("script-executed");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ // Load the background page that registers a content script.
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ `moz-extension://${extension.uuid}/background.html`,
+ true
+ );
+ await extension.awaitMessage("background-executed");
+ await AppTestDelegate.removeTab(window, tab);
+
+ // Load a page that will trigger the content scripts previously registered.
+ tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html",
+ true
+ );
+
+ await extension.awaitMessage("script-executed");
+
+ await AppTestDelegate.removeTab(window, tab);
+ await extension.unload();
+});
+
+add_task(async function test_scripts_dont_run_after_shutdown() {
+ let extension = makeExtension({
+ async background() {
+ await browser.scripting.registerContentScripts([
+ {
+ id: "script-that-should-not-run",
+ js: ["script.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ },
+ ]);
+
+ browser.test.sendMessage("background-ready");
+ },
+ files: {
+ "script.js": () => {
+ browser.test.fail("this script should not be executed.");
+ },
+ },
+ });
+ // We use a second extension to wait enough time to confirm that the script
+ // registered in the previous extension has not been executed at all, in case
+ // the tab closes before the scheduled content script has had a chance to
+ // run.
+ let anotherExtension = makeExtension({
+ async background() {
+ await browser.scripting.registerContentScripts([
+ {
+ id: "this-script-should-run",
+ js: ["script.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ },
+ ]);
+
+ browser.test.sendMessage("background-ready");
+ },
+ files: {
+ "script.js": () => {
+ browser.test.sendMessage("script-ran");
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ await anotherExtension.startup();
+ await anotherExtension.awaitMessage("background-ready");
+
+ await extension.unload();
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html",
+ true
+ );
+ await anotherExtension.awaitMessage("script-ran");
+ await AppTestDelegate.removeTab(window, tab);
+
+ await anotherExtension.unload();
+});
+
+add_task(async function test_registerContentScripts_with_wrong_matches() {
+ let extension = makeExtension({
+ async background() {
+ // Register a content script that should not be injected in this test
+ // case because the `matches` values don't match the host permissions.
+ await browser.scripting.registerContentScripts([
+ {
+ id: "script-that-should-not-run",
+ js: ["script.js"],
+ matches: ["*://mozilla.org/*"],
+ persistAcrossSessions: false,
+ },
+ ]);
+
+ browser.test.sendMessage("background-ready");
+ },
+ files: {
+ "script.js": () => {
+ browser.test.fail("this script should not be executed.");
+ },
+ },
+ });
+ // We use a second extension to wait enough time to confirm that the script
+ // registered in the previous extension has not been executed at all, in case
+ // the tab closes before the scheduled content script has had a chance to
+ // run.
+ let anotherExtension = makeExtension({
+ async background() {
+ await browser.scripting.registerContentScripts([
+ {
+ id: "this-script-should-run",
+ js: ["script.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ },
+ ]);
+
+ browser.test.sendMessage("background-ready");
+ },
+ files: {
+ "script.js": () => {
+ browser.test.sendMessage("script-ran");
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ await anotherExtension.startup();
+ await anotherExtension.awaitMessage("background-ready");
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html",
+ true
+ );
+ await anotherExtension.awaitMessage("script-ran");
+
+ await extension.unload();
+ await anotherExtension.unload();
+
+ // We remove the tab after having unloaded the extensions to avoid failures
+ // on Windows, see: Bug 1761550.
+ await AppTestDelegate.removeTab(window, tab);
+});
+
+add_task(async function test_registerContentScripts_twice_with_same_id() {
+ let extension = makeExtension({
+ async background() {
+ const script = {
+ id: "script-that-should-not-run",
+ js: ["script.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ };
+
+ const results = await Promise.allSettled([
+ browser.scripting.registerContentScripts([script]),
+ browser.scripting.registerContentScripts([script]),
+ ]);
+
+ browser.test.assertEq(2, results.length, "got expected length");
+ browser.test.assertEq(
+ "fulfilled",
+ results[0].status,
+ "expected fulfilled promise"
+ );
+ browser.test.assertEq(
+ "rejected",
+ results[1].status,
+ "expected rejected promise"
+ );
+ browser.test.assertEq(
+ `Content script with id "script-that-should-not-run" is already registered.`,
+ results[1].reason.message,
+ "expected reason"
+ );
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+
+ browser.test.sendMessage("background-done");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+ await extension.unload();
+});
+
+add_task(async function test_getRegisteredContentScripts_during_a_registration() {
+ let extension = makeExtension({
+ async background() {
+ browser.scripting.registerContentScripts([
+ {
+ id: "a-script",
+ js: ["script.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ },
+ ]);
+
+ const scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(
+ JSON.stringify([
+ {
+ id: "a-script",
+ allFrames: false,
+ matches: ["*://test1.example.com/*"],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ js: ["script.js"],
+ },
+ ]),
+ JSON.stringify(scripts),
+ "expected 1 registered script"
+ );
+
+ browser.test.sendMessage("background-done");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+ await extension.unload();
+});
+
+add_task(async function test_validate_unregisterContentScripts_params() {
+ let extension = makeExtension({
+ async background() {
+ const TEST_CASES = [
+ {
+ title: "unknown id",
+ params: {
+ ids: ["non-existent-id"],
+ },
+ expectedError: `Content script with id "non-existent-id" does not exist.`
+ },
+ {
+ title: "invalid id",
+ params: {
+ ids: ["_invalid-id"],
+ },
+ expectedError: "Invalid content script id.",
+ },
+ ];
+
+ for (const { title, params, expectedError } of TEST_CASES) {
+ await browser.test.assertRejects(
+ browser.scripting.unregisterContentScripts(params),
+ expectedError,
+ `${title} - got expected error`
+ );
+ }
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(0, scripts.length, "expected no script");
+
+ browser.test.sendMessage("background-done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+ await extension.unload();
+});
+
+add_task(async function test_unregisterContentScripts_with_chrome_compatible_callback() {
+ let extension = makeExtension({
+ async background() {
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(0, scripts.length, "expected no script");
+
+ // Register a script that we can unregister after.
+ await browser.scripting.registerContentScripts([
+ {
+ id: "script-1",
+ js: ["script.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ },
+ ]);
+ scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+
+ browser.test.log("test call with undefined filter and a chrome-compatible callback");
+ await new Promise(resolve => {
+ browser.scripting.unregisterContentScripts(undefined, resolve);
+ });
+
+ scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(0, scripts.length, "expected no registered scripts");
+
+ // Re-register a script that we can unregister after.
+ await browser.scripting.registerContentScripts([
+ {
+ id: "script-1",
+ js: ["script.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ },
+ ]);
+
+ browser.test.log("test call with only the chrome-compatible callback");
+ await new Promise(resolve => {
+ browser.scripting.unregisterContentScripts(resolve);
+ });
+
+ scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(0, scripts.length, "expected no registered scripts");
+
+ browser.test.sendMessage("background-done");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+ await extension.unload();
+});
+
+add_task(async function test_unregisterContentScripts() {
+ let extension = makeExtension({
+ async background() {
+ const script1 = {
+ id: "script-1",
+ js: ["script.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ };
+ const script2 = {
+ id: "script-2",
+ js: ["script.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ }
+ const script3 = {
+ id: "script-3",
+ js: ["script.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ }
+
+ let res = await browser.scripting.registerContentScripts([
+ script1,
+ script2,
+ script3,
+ ]);
+ browser.test.assertEq(undefined, res, "expected no result");
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(3, scripts.length, "expected 3 scripts");
+ browser.test.assertEq(script1.id, scripts[0].id, "expected correct id");
+ browser.test.assertEq(script2.id, scripts[1].id, "expected correct id");
+ browser.test.assertEq(script3.id, scripts[2].id, "expected correct id");
+
+ // No unregistration when unknown IDs are passed along with valid IDs.
+ await browser.test.assertRejects(
+ browser.scripting.unregisterContentScripts({
+ ids: [script2.id, "non-existent-id"],
+ }),
+ `Content script with id "non-existent-id" does not exist.`
+ );
+
+ scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(3, scripts.length, "expected 3 scripts");
+
+ // Unregister 1 script.
+ res = await browser.scripting.unregisterContentScripts({
+ ids: [script2.id]
+ });
+ browser.test.assertEq(undefined, res, "expected no result");
+
+ scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(2, scripts.length, "expected 2 scripts");
+ browser.test.assertEq(script1.id, scripts[0].id, "expected correct id");
+ browser.test.assertEq(script3.id, scripts[1].id, "expected correct id");
+
+ // This should unregister all the remaining registered scripts.
+ res = await browser.scripting.unregisterContentScripts();
+ browser.test.assertEq(undefined, res, "expected no result");
+
+ scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(0, scripts.length, "expected no script");
+
+ browser.test.sendMessage("background-done");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+ await extension.unload();
+});
+
+add_task(async function test_unregisterContentScripts_twice_with_same_id() {
+ let extension = makeExtension({
+ async background() {
+ const script = {
+ id: "script-to-unregister",
+ js: ["script.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ };
+
+ await browser.scripting.registerContentScripts([script]);
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+
+ const results = await Promise.allSettled([
+ browser.scripting.unregisterContentScripts({ ids: [script.id] }),
+ browser.scripting.unregisterContentScripts({ ids: [script.id] }),
+ ]);
+
+ browser.test.assertEq(2, results.length, "got expected length");
+ browser.test.assertEq(
+ "fulfilled",
+ results[0].status,
+ "expected fulfilled promise"
+ );
+ browser.test.assertEq(
+ "rejected",
+ results[1].status,
+ "expected rejected promise"
+ );
+ browser.test.assertEq(
+ `Content script with id "script-to-unregister" does not exist.`,
+ results[1].reason.message,
+ "expected reason"
+ );
+
+ scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(0, scripts.length, "expected 0 registered script");
+
+ browser.test.sendMessage("background-done");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+ await extension.unload();
+});
+
+add_task(async function test_validate_updateContentScripts_params() {
+ let extension = makeExtension({
+ async background() {
+ const script = {
+ id: "registered-script",
+ js: ["script-1.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ };
+
+ const TEST_CASES = [
+ {
+ title: "invalid script ID",
+ params: [
+ {
+ id: "_invalid-id",
+ },
+ ],
+ expectedError: 'Invalid content script id.',
+ },
+ {
+ title: "empty script ID",
+ params: [
+ {
+ id: "",
+ },
+ ],
+ expectedError: 'Invalid content script id.',
+ },
+ {
+ title: "unknown script ID",
+ params: [
+ {
+ id: "unknown-id",
+ },
+ ],
+ expectedError: 'Content script with id "unknown-id" does not exist.',
+ },
+ {
+ title: "duplicate valid script IDs",
+ params: [
+ {
+ id: script.id,
+ },
+ {
+ id: script.id,
+ },
+ ],
+ expectedError: `Script ID "${script.id}" found more than once in 'scripts' array.`,
+ },
+ {
+ title: "empty matches",
+ params: [
+ {
+ id: script.id,
+ matches: [],
+ },
+ ],
+ expectedError: "matches must be specified.",
+ },
+ {
+ title: "one empty match",
+ params: [
+ {
+ id: script.id,
+ matches: [""],
+ },
+ ],
+ expectedError: "Invalid url pattern: ",
+ },
+ {
+ title: "invalid match",
+ params: [
+ {
+ id: script.id,
+ matches: ["not-a-pattern"],
+ },
+ ],
+ expectedError: "Invalid url pattern: not-a-pattern",
+ },
+ {
+ title: "invalid match and valid match",
+ params: [
+ {
+ id: script.id,
+ matches: ["*://mochi.test/*", "not-a-pattern"],
+ },
+ ],
+ expectedError: "Invalid url pattern: not-a-pattern",
+ },
+ {
+ title: "one empty value in excludeMatches",
+ params: [
+ {
+ id: script.id,
+ excludeMatches: [""],
+ },
+ ],
+ expectedError: "Invalid url pattern: ",
+ },
+ {
+ title: "invalid value in excludeMatches",
+ params: [
+ {
+ id: script.id,
+ excludeMatches: ["not-a-pattern"],
+ },
+ ],
+ expectedError: "Invalid url pattern: not-a-pattern",
+ },
+ {
+ title: "empty js",
+ params: [
+ {
+ id: script.id,
+ js: [],
+ },
+ ],
+ expectedError: "At least one js or css must be specified.",
+ },
+ {
+ title: "empty js and css",
+ params: [
+ {
+ id: script.id,
+ js: [],
+ css: [],
+ },
+ ],
+ expectedError: "At least one js or css must be specified.",
+ },
+ ];
+
+ // Register a valid script so that we can verify update params beyond
+ // script IDs.
+ await browser.scripting.registerContentScripts([script]);
+
+ for (const { title, params, expectedError } of TEST_CASES) {
+ await browser.test.assertRejects(
+ browser.scripting.updateContentScripts(params),
+ expectedError,
+ `${title} - got expected error`
+ );
+ }
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+ browser.test.assertEq(
+ JSON.stringify([
+ {
+ id: script.id,
+ allFrames: false,
+ matches: script.matches,
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ js: script.js,
+ },
+ ]),
+ JSON.stringify(scripts),
+ "expected script to not have been modified"
+ );
+
+ browser.test.sendMessage("background-done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+ await extension.unload();
+});
+
+add_task(async function test_updateContentScripts() {
+ let extension = makeExtension({
+ async background() {
+ const SCRIPT_ID = "script-to-update";
+
+ await browser.scripting.registerContentScripts([
+ {
+ id: SCRIPT_ID,
+ js: ["script-1.js"],
+ matches: ["*://test1.example.com/*"],
+ persistAcrossSessions: false,
+ },
+ ]);
+
+ browser.test.onMessage.addListener(async (msg, params) => {
+ switch (msg) {
+ case "updateContentScripts": {
+ const {
+ title,
+ updateContentScriptsParams,
+ expectedRegisteredContentScript
+ } = params;
+
+ let result = await browser.scripting.updateContentScripts([
+ updateContentScriptsParams,
+ ]);
+ browser.test.assertEq(
+ undefined,
+ result,
+ `${title} - expected no return value`
+ );
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(
+ JSON.stringify([expectedRegisteredContentScript]),
+ JSON.stringify(scripts),
+ `${title} - expected registered script`
+ );
+
+ browser.test.sendMessage(`${msg}-done`);
+ break;
+ }
+
+ default:
+ browser.test.fail(`invalid message received: ${msg}`);
+ }
+ });
+
+ browser.test.sendMessage("background-ready");
+ },
+ files: {
+ "script-1.js": () => {
+ browser.test.sendMessage(
+ `script-1 executed in ${location.pathname.split("/").pop()}`
+ );
+ },
+ "script-2.js": () => {
+ browser.test.sendMessage(
+ `script-2 executed in ${location.pathname.split("/").pop()}`
+ );
+ },
+ "script-3.js": () => {
+ browser.test.sendMessage(
+ `script-3 executed in ${location.pathname.split("/").pop()}`
+ );
+ },
+ "script-4.js": () => {
+ browser.test.sendMessage(
+ `script-4 executed in ${location.pathname.split("/").pop()}`
+ );
+ },
+ "style.css": "body { background-color: rgb(0, 255, 0); }",
+ "script-check-style.js": () => {
+ browser.test.assertEq(
+ "rgb(0, 255, 0)",
+ getComputedStyle(document.querySelector('body')).backgroundColor,
+ "expected background color"
+ );
+ browser.test.sendMessage(
+ `script-check-style executed in ${location.pathname.split("/").pop()}`
+ );
+ },
+ },
+ });
+
+ const SCRIPT_ID = "script-to-update";
+ const TEST_PAGE = "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html";
+
+ const runTestCase = async ({
+ title,
+ updateContentScriptsParams,
+ expectedRegisteredContentScript,
+ expectedMessages
+ }) => {
+ // Register content script and verify results.
+ extension.sendMessage("updateContentScripts", {
+ title,
+ updateContentScriptsParams,
+ expectedRegisteredContentScript,
+ });
+ await extension.awaitMessage("updateContentScripts-done");
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ TEST_PAGE,
+ true
+ );
+
+ await Promise.all(expectedMessages.map(msg => extension.awaitMessage(msg)));
+
+ await AppTestDelegate.removeTab(window, tab);
+ };
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ // Load a page that will trigger the content script initially registered.
+ let tab = await AppTestDelegate.openNewForegroundTab(window, TEST_PAGE, true);
+ await extension.awaitMessage("script-1 executed in file_contains_iframe.html");
+ await AppTestDelegate.removeTab(window, tab);
+
+ // Now, let's update this content script a few times.
+ await runTestCase({
+ title: "update ID only",
+ updateContentScriptsParams: {
+ id: SCRIPT_ID,
+ },
+ expectedRegisteredContentScript: {
+ id: SCRIPT_ID,
+ allFrames: false,
+ matches: ["*://test1.example.com/*"],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ js: ["script-1.js"],
+ },
+ expectedMessages: ["script-1 executed in file_contains_iframe.html"],
+ });
+
+ await runTestCase({
+ title: "update js",
+ updateContentScriptsParams: {
+ id: SCRIPT_ID,
+ js: ["script-2.js"],
+ },
+ expectedRegisteredContentScript: {
+ id: SCRIPT_ID,
+ allFrames: false,
+ matches: ["*://test1.example.com/*"],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ js: ["script-2.js"],
+ },
+ expectedMessages: ["script-2 executed in file_contains_iframe.html"],
+ });
+
+ await runTestCase({
+ title: "update allFrames and matches",
+ updateContentScriptsParams: {
+ id: SCRIPT_ID,
+ matches: [
+ "*://test1.example.com/*",
+ "*://example.org/*",
+ ],
+ allFrames: true,
+ },
+ expectedRegisteredContentScript: {
+ id: SCRIPT_ID,
+ allFrames: true,
+ matches: [
+ "*://test1.example.com/*",
+ "*://example.org/*",
+ ],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ js: ["script-2.js"],
+ },
+ expectedMessages: [
+ "script-2 executed in file_contains_iframe.html",
+ "script-2 executed in file_contains_img.html",
+ ],
+ });
+
+ await runTestCase({
+ title: "update excludeMatches and js",
+ updateContentScriptsParams: {
+ id: SCRIPT_ID,
+ js: ["script-3.js"],
+ excludeMatches: ["*://test1.example.com/*"],
+ allFrames: true,
+ },
+ expectedRegisteredContentScript: {
+ id: SCRIPT_ID,
+ allFrames: true,
+ matches: [
+ "*://test1.example.com/*",
+ "*://example.org/*",
+ ],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ excludeMatches: ["*://test1.example.com/*"],
+ js: ["script-3.js"],
+ },
+ expectedMessages: [
+ "script-3 executed in file_contains_img.html",
+ ],
+ });
+
+ await runTestCase({
+ title: "update allFrames, excludeMatches, js and runAt",
+ updateContentScriptsParams: {
+ id: SCRIPT_ID,
+ allFrames: false,
+ excludeMatches: [],
+ js: ["script-4.js"],
+ runAt: "document_start",
+ },
+ expectedRegisteredContentScript: {
+ id: SCRIPT_ID,
+ allFrames: false,
+ matches: [
+ "*://test1.example.com/*",
+ "*://example.org/*",
+ ],
+ runAt: "document_start",
+ persistAcrossSessions: false,
+ js: ["script-4.js"],
+ },
+ expectedMessages: [
+ "script-4 executed in file_contains_iframe.html",
+ ],
+ });
+
+ await runTestCase({
+ title: "update allFrames, css, js and runAt",
+ updateContentScriptsParams: {
+ id: SCRIPT_ID,
+ allFrames: true,
+ css: ["style.css"],
+ js: ["script-check-style.js"],
+ runAt: "document_idle",
+ },
+ expectedRegisteredContentScript: {
+ id: SCRIPT_ID,
+ allFrames: true,
+ matches: [
+ "*://test1.example.com/*",
+ "*://example.org/*",
+ ],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ css: ["style.css"],
+ js: ["script-check-style.js"],
+ },
+ expectedMessages: [
+ "script-check-style executed in file_contains_iframe.html",
+ "script-check-style executed in file_contains_img.html",
+ ],
+ });
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript.html
new file mode 100644
index 0000000000..a2d741606f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript.html
@@ -0,0 +1,1479 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Tests scripting.executeScript()</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<iframe src="https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"></iframe>
+
+<script type="text/javascript">
+
+"use strict";
+
+const MOCHITEST_HOST_PERMISSIONS = [
+ "*://mochi.test/",
+ "*://mochi.xorigin-test/",
+ "*://test1.example.com/",
+];
+
+const makeExtension = ({ manifest: manifestProps, ...otherProps }) => {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["scripting"],
+ host_permissions: [
+ ...MOCHITEST_HOST_PERMISSIONS,
+ "https://example.com/",
+ // Used in `file_contains_iframe.html`
+ "https://example.org/",
+ ],
+ granted_host_permissions: true,
+ ...manifestProps,
+ },
+ useAddonManager: "temporary",
+ ...otherProps,
+ });
+};
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", true]],
+ });
+});
+
+add_task(async function test_executeScript_params_validation() {
+ let extension = makeExtension({
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ const tabId = tabs[0].id;
+
+ const TEST_CASES = [
+ {
+ title: "no files and no func",
+ executeScriptParams: {},
+ expectedError: /Exactly one of files and func must be specified/,
+ },
+ {
+ title: "both files and func are passed",
+ executeScriptParams: { files: ["script.js"], func() {} },
+ expectedError: /Exactly one of files and func must be specified/,
+ },
+ {
+ title: "non-empty args is passed with files",
+ executeScriptParams: { files: ["script.js"], args: [123] },
+ expectedError: /'args' may not be used with file injections/,
+ },
+ {
+ title: "empty args is passed with files",
+ executeScriptParams: { files: ["script.js"], args: [] },
+ expectedError: /'args' may not be used with file injections/,
+ },
+ {
+ title: "unserializable argument",
+ executeScriptParams: { func() {}, args: [window] },
+ expectedError: /Unserializable arguments/,
+ },
+ {
+ title: "both allFrames and frameIds are passed",
+ executeScriptParams: {
+ target: {
+ tabId,
+ allFrames: true,
+ frameIds: [1, 2, 3],
+ },
+ files: ["script.js"],
+ },
+ expectedError: /Cannot specify both 'allFrames' and 'frameIds'/,
+ },
+ {
+ title: "invalid IDs in frameIds",
+ executeScriptParams: {
+ target: { tabId, frameIds: [0, 1, 2] },
+ func: () => {},
+ },
+ expectedError: "Invalid frame IDs: [1, 2].",
+ },
+ {
+ title: "throw non-structurally cloneable data in all frames",
+ executeScriptParams: {
+ target: {
+ tabId,
+ allFrames: true,
+ },
+ func: () => {
+ throw window;
+ },
+ },
+ expectedError: /Script '<anonymous code>' result is non-structured-clonable data/,
+ },
+ ];
+
+ for (const { title, executeScriptParams, expectedError } of TEST_CASES) {
+ await browser.test.assertRejects(
+ browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ ...executeScriptParams,
+ }),
+ expectedError,
+ `expected error when: ${title}`
+ );
+ }
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+});
+
+add_task(async function test_executeScript_main_world() {
+ let extension = makeExtension({
+ async background() {
+ browser.test.assertThrows(
+ () => {
+ browser.scripting.executeScript({
+ target: { tabId: 123 },
+ func: () => {},
+ world: "MAIN",
+ });
+ },
+ /world: Invalid enumeration value "MAIN"/,
+ "expected 'MAIN' world to not be supported yet"
+ );
+
+ browser.test.notifyPass("background-done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("background-done");
+ await extension.unload();
+});
+
+add_task(async function test_executeScript_isolated_world() {
+ let extension = makeExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "@isolated-addon-id" },
+ },
+ },
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ let results = await browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ func: () => {
+ globalThis.defaultWorldVar = browser.runtime.id;
+ return "default world";
+ },
+ });
+
+ browser.test.assertEq(
+ 1,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(
+ "default world",
+ results[0].result,
+ "got expected return value"
+ );
+
+ results = await browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ func: () => {
+ return `isolated: ${browser.runtime.id}; existing default var: ${typeof defaultWorldVar}`;
+ },
+ world: "ISOLATED",
+ });
+
+ browser.test.assertEq(
+ 1,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(
+ "isolated: @isolated-addon-id; existing default var: string",
+ results[0].result,
+ "got expected return value"
+ );
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html",
+ true
+ );
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+
+ await AppTestDelegate.removeTab(window, tab);
+});
+
+add_task(async function test_execution_world_constants() {
+ let extension = makeExtension({
+ async background() {
+ browser.test.assertTrue(
+ !!browser.scripting.ExecutionWorld,
+ "expected scripting.ExecutionWorld to be defined"
+ );
+ browser.test.assertEq(
+ 1,
+ Object.keys(browser.scripting.ExecutionWorld).length,
+ "expected 1 ExecutionWorld constant"
+ );
+ browser.test.assertEq(
+ "ISOLATED",
+ browser.scripting.ExecutionWorld.ISOLATED,
+ "expected ISOLATED constant to be defined"
+ );
+ // TODO: Bug 1736575 - Add support for other execution worlds like MAIN.
+ browser.test.assertEq(
+ undefined,
+ browser.scripting.ExecutionWorld.MAIN,
+ "expected MAIN constant to be undefined"
+ );
+
+ browser.test.notifyPass("background-done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("background-done");
+ await extension.unload();
+});
+
+add_task(async function test_executeScript_with_wrong_host_permissions() {
+ let extension = makeExtension({
+ manifest: {
+ host_permissions: [],
+ },
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ await browser.test.assertRejects(
+ browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ func: () => {
+ browser.test.fail("Unexpected execution");
+ },
+ }),
+ "Missing host permission for the tab",
+ "expected host permission error"
+ );
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+});
+
+add_task(async function test_executeScript_with_invalid_tabId() {
+ let extension = makeExtension({
+ async background() {
+ // This tab ID should not exist.
+ const tabId = 123456789;
+
+ await browser.test.assertRejects(
+ browser.scripting.executeScript({
+ target: { tabId },
+ func: () => {
+ browser.test.fail("Unexpected execution");
+ },
+ }),
+ `Invalid tab ID: ${tabId}`
+ );
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+});
+
+add_task(async function test_executeScript_with_func() {
+ let extension = makeExtension({
+ async background() {
+ const getTitle = () => {
+ return document.title;
+ };
+
+ const tabs = await browser.tabs.query({ active: true });
+
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const results = await browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ func: getTitle,
+ });
+
+ browser.test.assertEq(
+ 1,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(
+ "file sample",
+ results[0].result,
+ "got the expected title"
+ );
+ browser.test.assertEq(0, results[0].frameId, "got the expected frameId");
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html",
+ true
+ );
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+
+ await AppTestDelegate.removeTab(window, tab);
+});
+
+add_task(async function test_executeScript_with_func_and_args() {
+ let extension = makeExtension({
+ async background() {
+ const formatArgs = (a, b, c) => {
+ return `received ${a}, ${b} and ${c}`;
+ };
+
+ const tabs = await browser.tabs.query({ active: true });
+
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const results = await browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ func: formatArgs,
+ args: [true, undefined, "str"],
+ });
+
+ browser.test.assertEq(
+ 1,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(
+ // undefined is converted to null when json-stringified in an array.
+ "received true, null and str",
+ results[0].result,
+ "got the expected return value"
+ );
+ browser.test.assertEq(0, results[0].frameId, "got the expected frameId");
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+});
+
+add_task(async function test_executeScript_returns_nothing() {
+ let extension = makeExtension({
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const results = await browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ func: () => {},
+ });
+
+ browser.test.assertEq(
+ 1,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(
+ undefined,
+ results[0].result,
+ "got expected undefined result"
+ );
+ browser.test.assertEq(0, results[0].frameId, "got the expected frameId");
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+});
+
+add_task(async function test_executeScript_returns_null() {
+ let extension = makeExtension({
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const results = await browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ func: () => {
+ return null;
+ },
+ });
+
+ browser.test.assertEq(
+ 1,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(
+ null,
+ results[0].result,
+ "got expected null result"
+ );
+ browser.test.assertEq(0, results[0].frameId, "got the expected frameId");
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+});
+
+add_task(async function test_executeScript_with_error_in_func() {
+ let extension = makeExtension({
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const results = await browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ func: () => {
+ throw new Error(`Thrown at ${location.pathname.split("/").pop()}`);
+ },
+ });
+
+ browser.test.assertEq(
+ 1,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(0, results[0].frameId, "got the expected frameId");
+ browser.test.assertEq(
+ "Thrown at file_sample.html",
+ results[0].error.message,
+ "got the expected error message"
+ );
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html",
+ true
+ );
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+
+ await AppTestDelegate.removeTab(window, tab);
+});
+
+add_task(async function test_executeScript_with_a_file() {
+ let extension = makeExtension({
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const results = await browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ files: ["script.js"],
+ });
+
+ browser.test.assertEq(
+ 1,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(
+ "value from script.js",
+ results[0].result,
+ "got the expected result"
+ );
+ browser.test.assertEq(0, results[0].frameId, "got the expected frameId");
+
+ browser.test.notifyPass("execute-script");
+ },
+ files: {
+ "script.js": function() {
+ return "value from script.js";
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+});
+
+add_task(async function test_executeScript_in_one_frame() {
+ let extension = makeExtension({
+ manifest: {
+ permissions: ["scripting", "webNavigation"],
+ },
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const tabId = tabs[0].id;
+ const frames = await browser.webNavigation.getAllFrames({ tabId });
+ // 1. Top-level frame with the MochiTest runner
+ // 2. Frame for this file
+ // 3. Frame that loads `file_sample.html` at the top of this file
+ browser.test.assertEq(3, frames.length, "expected 3 frames");
+
+ const fileSampleFrameId = frames[2].frameId;
+ browser.test.assertTrue(
+ frames[2].url.includes("file_sample.html"),
+ "expected frame URL"
+ );
+
+ const TEST_CASES = [
+ {
+ title: "with a file and a frame ID",
+ params: {
+ target: { tabId, frameIds: [fileSampleFrameId] },
+ files: ["script.js"],
+ },
+ expectedResults: [
+ {
+ frameId: fileSampleFrameId,
+ result: "Sample text",
+ },
+ ],
+ },
+ {
+ title: "with no frame ID",
+ params: {
+ target: { tabId },
+ func: () => {
+ return 123;
+ },
+ },
+ expectedResults: [{ frameId: 0, result: 123 }],
+ },
+ ];
+
+ for (const { title, params, expectedResults } of TEST_CASES) {
+ const results = await browser.scripting.executeScript(params);
+
+ browser.test.assertEq(
+ expectedResults.length,
+ results.length,
+ `${title} - got expected number of results`
+ );
+ expectedResults.forEach(({ frameId, result }, index) => {
+ browser.test.assertEq(
+ result,
+ results[index].result,
+ `${title} - got the expected results[${index}].result`
+ );
+ browser.test.assertEq(
+ frameId,
+ results[index].frameId,
+ `${title} - got the expected results[${index}].frameId`
+ );
+ });
+ }
+
+ browser.test.notifyPass("execute-script");
+ },
+ files: {
+ "script.js": function() {
+ return document.getElementById("test").textContent;
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+});
+
+add_task(async function test_executeScript_in_multiple_frameIds() {
+ let extension = makeExtension({
+ manifest: {
+ permissions: ["scripting", "webNavigation"],
+ },
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const tabId = tabs[0].id;
+ const frames = await browser.webNavigation.getAllFrames({ tabId });
+ // 1. Top-level frame that loads `file_contains_iframe.html`
+ // 2. Frame that loads `file_contains_img.html`
+ browser.test.assertEq(2, frames.length, "expected 2 frames");
+
+ const frameIds = frames.map(frame => frame.frameId);
+
+ const getTitle = () => {
+ return document.title;
+ };
+
+ const TEST_CASES = [
+ {
+ title: "multiple frame IDs",
+ params: {
+ target: { tabId, frameIds },
+ func: getTitle,
+ },
+ expectedResults: [
+ {
+ frameId: frameIds[0],
+ result: "file contains iframe",
+ },
+ {
+ frameId: frameIds[1],
+ result: "file contains img",
+ },
+ ],
+ },
+ {
+ title: "empty list of frame IDs",
+ params: {
+ target: { tabId, frameIds: [] },
+ func: getTitle,
+ },
+ expectedResults: [],
+ },
+ ];
+
+ for (const { title, params, expectedResults } of TEST_CASES) {
+ const results = await browser.scripting.executeScript(params);
+
+ browser.test.assertEq(
+ expectedResults.length,
+ results.length,
+ `${title} - got expected number of results`
+ );
+ // Sort injection results by frameId to always assert the results in
+ // the same order.
+ results.sort((a, b) => a.frameId - b.frameId);
+
+ browser.test.assertEq(
+ JSON.stringify(expectedResults),
+ JSON.stringify(results),
+ `${title} - got expected results`
+ );
+ }
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html",
+ true
+ );
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+
+ await AppTestDelegate.removeTab(window, tab);
+});
+
+add_task(async function test_executeScript_with_errors_in_multiple_frameIds() {
+ let extension = makeExtension({
+ manifest: {
+ permissions: ["scripting", "webNavigation"],
+ },
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const tabId = tabs[0].id;
+ const frames = await browser.webNavigation.getAllFrames({ tabId });
+ // 1. Top-level frame that loads `file_contains_iframe.html`
+ // 2. Frame that loads `file_contains_img.html`
+ browser.test.assertEq(2, frames.length, "expected 2 frames");
+
+ const frameIds = frames.map(frame => frame.frameId);
+
+ const results = await browser.scripting.executeScript({
+ target: { tabId, frameIds },
+ func: () => {
+ throw new Error(`Thrown at ${location.pathname.split("/").pop()}`);
+ },
+ });
+
+ browser.test.assertEq(
+ 2,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(
+ "Thrown at file_contains_iframe.html",
+ results[0].error.message,
+ "got expected error message in results[0]"
+ );
+ browser.test.assertEq(
+ "Thrown at file_contains_img.html",
+ results[1].error.message,
+ "got expected error message in results[1]"
+ );
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html",
+ true
+ );
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+
+ await AppTestDelegate.removeTab(window, tab);
+});
+
+add_task(async function test_executeScript_with_frameId_and_wrong_host_permission() {
+ let extension = makeExtension({
+ manifest: {
+ host_permissions: MOCHITEST_HOST_PERMISSIONS,
+ permissions: ["scripting", "webNavigation"],
+ },
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const tabId = tabs[0].id;
+ const frames = await browser.webNavigation.getAllFrames({ tabId });
+ // 1. Top-level frame with the MochiTest runner
+ // 2. Frame for this file
+ // 3. Frame that loads `file_sample.html` at the top of this file
+ browser.test.assertEq(3, frames.length, "expected 3 frames");
+
+ const frameIds = frames.map(frame => frame.frameId);
+
+ await browser.test.assertRejects(
+ browser.scripting.executeScript({
+ target: { tabId, frameIds: [frameIds[2]] },
+ func: () => {
+ browser.test.fail("Unexpected execution");
+ },
+ }),
+ "Missing host permission for the tab or frames",
+ "got the expected error message"
+ );
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+});
+
+add_task(async function test_executeScript_with_multiple_frameIds_and_wrong_host_permissions() {
+ let extension = makeExtension({
+ manifest: {
+ host_permissions: MOCHITEST_HOST_PERMISSIONS,
+ permissions: ["scripting", "webNavigation"],
+ },
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const tabId = tabs[0].id;
+ const frames = await browser.webNavigation.getAllFrames({ tabId });
+ // 1. Top-level frame with the MochiTest runner
+ // 2. Frame for this file
+ // 3. Frame that loads `file_sample.html` at the top of this file
+ browser.test.assertEq(3, frames.length, "expected 3 frames");
+
+ const frameIds = frames.map(frame => frame.frameId);
+
+ const results = await browser.scripting.executeScript({
+ target: { tabId, frameIds },
+ func: () => {},
+ });
+
+ // We get 2 results because we cannot inject into the 3rd frame.
+ browser.test.assertEq(
+ 2,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertTrue(
+ typeof results[0].error === "undefined",
+ "expected no error in results[0]"
+ );
+ browser.test.assertTrue(
+ typeof results[1].error === "undefined",
+ "expected no error in results[1]"
+ );
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+});
+
+add_task(async function test_executeScript_with_iframe_srcdoc_and_aboutblank() {
+ let iframe = document.createElement("iframe");
+ iframe.srcdoc = `<!DOCTYPE html>
+ <html>
+ <head><title>iframe with srcdoc</title></head>
+ </html>`;
+ await new Promise(resolve => {
+ iframe.onload = resolve;
+ document.body.appendChild(iframe);
+ });
+
+ let iframeAboutBlank = document.createElement("iframe");
+ iframeAboutBlank.src = "about:blank";
+ await new Promise(resolve => {
+ iframeAboutBlank.onload = resolve;
+ document.body.appendChild(iframeAboutBlank);
+ });
+
+ let extension = makeExtension({
+ manifest: {
+ permissions: ["scripting", "webNavigation"],
+ },
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const tabId = tabs[0].id;
+ const frames = await browser.webNavigation.getAllFrames({ tabId });
+ // 1. Top-level frame with the MochiTest runner
+ // 2. Frame for this file
+ // 3. Frame that loads `file_sample.html` at the top of this file
+ // 4. Frame that loads the `srcdoc`
+ // 5. Frame for `about:blank`
+ browser.test.assertEq(5, frames.length, "expected 5 frames");
+
+ const frameIds = frames.map(frame => frame.frameId);
+
+ const TEST_CASES = [
+ {
+ title: "with frameIds for all frames",
+ params: {
+ target: { tabId, frameIds },
+ },
+ expectedResults: {
+ count: 5,
+ entriesAtIndex: {
+ 3: {
+ frameId: frameIds[3],
+ result: "iframe with srcdoc",
+ },
+ 4: {
+ frameId: frameIds[4],
+ result: "about:blank",
+ },
+ },
+ },
+ },
+ {
+ title: "with allFrames: true",
+ params: {
+ target: { tabId, allFrames: true },
+ },
+ expectedResults: {
+ count: 5,
+ entriesAtIndex: {
+ 3: {
+ frameId: frameIds[3],
+ result: "iframe with srcdoc",
+ },
+ 4: {
+ frameId: frameIds[4],
+ result: "about:blank",
+ },
+ },
+ },
+ },
+ {
+ title: "with a single frame specified",
+ params: {
+ target: { tabId, frameIds: [frameIds[3]] },
+ },
+ expectedResults: {
+ count: 1,
+ entriesAtIndex: {
+ 0: {
+ frameId: frameIds[3],
+ result: "iframe with srcdoc",
+ },
+ },
+ },
+ },
+ ];
+
+ for (const { title, params, expectedResults } of TEST_CASES) {
+ const results = await browser.scripting.executeScript({
+ ...params,
+ func: () => {
+ return document.title || document.URL;
+ },
+ });
+ // Sort injection results by frameId to always assert the results in
+ // the same order.
+ results.sort((a, b) => a.frameId - b.frameId);
+
+ browser.test.assertEq(
+ expectedResults.count,
+ results.length,
+ `${title} - got the expected number of results`
+ );
+ Object.keys(expectedResults.entriesAtIndex).forEach(index => {
+ browser.test.assertEq(
+ JSON.stringify(expectedResults.entriesAtIndex[index]),
+ JSON.stringify(results[index]),
+ `${title} - got expected results[${index}]`
+ );
+ });
+ }
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+
+ iframe.remove();
+ iframeAboutBlank.remove();
+});
+
+add_task(async function test_executeScript_with_multiple_files() {
+ let extension = makeExtension({
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const results = await browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ files: ["1.js", "2.js"],
+ });
+
+ browser.test.assertEq(
+ 1,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(
+ "value from 2.js",
+ results[0].result,
+ "got the expected result"
+ );
+ browser.test.assertEq(0, results[0].frameId, "got the expected frameId");
+
+ browser.test.notifyPass("execute-script");
+ },
+ files: {
+ "1.js": function() {
+ return "value from 1.js";
+ },
+ "2.js": function() {
+ return "value from 2.js";
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+});
+
+add_task(async function test_executeScript_with_multiple_files_and_an_error() {
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html",
+ true
+ );
+
+ let extension = makeExtension({
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const results = await browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ files: ["1.js", "2.js"],
+ });
+
+ browser.test.assertEq(
+ 1,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(0, results[0].frameId, "got the expected frameId");
+ browser.test.assertEq(
+ "Thrown at file_contains_iframe.html",
+ results[0].error.message,
+ "got the expected error message"
+ );
+
+ browser.test.notifyPass("execute-script");
+ },
+ files: {
+ "1.js": function() {
+ throw new Error(`Thrown at ${location.pathname.split("/").pop()}`);
+ },
+ "2.js": function() {
+ return "value from 2.js";
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+
+ await AppTestDelegate.removeTab(window, tab);
+});
+
+add_task(async function test_executeScript_with_file_not_in_extension() {
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html",
+ true
+ );
+
+ let extension = makeExtension({
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ await browser.test.assertRejects(
+ browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ files: ["https://example.com/script.js"],
+ }),
+ /Files to be injected must be within the extension/,
+ "got the expected error message"
+ );
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+
+ await AppTestDelegate.removeTab(window, tab);
+});
+
+add_task(async function test_executeScript_allFrames() {
+ let extension = makeExtension({
+ manifest: {
+ permissions: ["scripting", "webNavigation"],
+ },
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const tabId = tabs[0].id;
+ const frames = await browser.webNavigation.getAllFrames({ tabId });
+ // 1. Top-level frame that loads `file_contains_iframe.html`
+ // 2. Frame that loads `file_contains_img.html`
+ browser.test.assertEq(2, frames.length, "expected 2 frames");
+ const frameIds = frames.map(frame => frame.frameId);
+
+ const getTitle = () => {
+ return document.title;
+ };
+
+ const TEST_CASES = [
+ {
+ title: "allFrames set to true",
+ scriptingParams: {
+ target: { tabId, allFrames: true },
+ func: getTitle,
+ },
+ expectedResults: [
+ {
+ frameId: frameIds[0],
+ result: "file contains iframe",
+ },
+ {
+ frameId: frameIds[1],
+ result: "file contains img",
+ },
+ ],
+ },
+ {
+ title: "allFrames set to false",
+ scriptingParams: {
+ target: { tabId, allFrames: false },
+ func: getTitle,
+ },
+ expectedResults: [
+ {
+ frameId: frameIds[0],
+ result: "file contains iframe",
+ },
+ ],
+ },
+ ];
+
+ for (const { title, scriptingParams, expectedResults } of TEST_CASES) {
+ const results = await browser.scripting.executeScript(scriptingParams);
+ // Sort injection results by frameId to always assert the results in
+ // the same order.
+ results.sort((a, b) => a.frameId - b.frameId);
+
+ browser.test.assertDeepEq(
+ expectedResults,
+ results,
+ `${title} - got expected results`
+ );
+
+ // Make sure the `error` prop is never set.
+ for (const result of results) {
+ browser.test.assertFalse(
+ "error" in result,
+ `${title} - expected error property to be unset`
+ );
+ }
+ }
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html",
+ true
+ );
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+
+ await AppTestDelegate.removeTab(window, tab);
+});
+
+add_task(async function test_executeScript_runtime_errors() {
+ let extension = makeExtension({
+ manifest: {
+ permissions: ["scripting", "webNavigation"],
+ },
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const tabId = tabs[0].id;
+ const frames = await browser.webNavigation.getAllFrames({ tabId });
+ // 1. Top-level frame that loads `file_contains_iframe.html`
+ // 2. Frame that loads `file_contains_img.html`
+ browser.test.assertEq(2, frames.length, "expected 2 frames");
+
+ const TEST_CASES = [
+ {
+ title: "reference error",
+ scriptingParams: {
+ target: { tabId },
+ func: () => {
+ // We do not define `e` on purpose.
+ // eslint-disable-next-line no-undef
+ return String(e);
+ },
+ },
+ expectedErrors: [
+ { type: "Error", stringRepr: "ReferenceError: e is not defined" },
+ ],
+ },
+ {
+ title: "eval error",
+ scriptingParams: {
+ target: { tabId },
+ func: () => {
+ // We use `eval()` on purpose.
+ // eslint-disable-next-line no-eval
+ eval("");
+ },
+ },
+ expectedErrors: [
+ { type: "Error", stringRepr: "EvalError: call to eval() blocked by CSP" },
+ ],
+ },
+ {
+ title: "errors thrown in allFrames",
+ scriptingParams: {
+ target: { tabId, allFrames: true },
+ func: () => {
+ throw new Error(`Thrown at ${location.pathname.split("/").pop()}`);
+ },
+ },
+ expectedErrors: [
+ { type: "Error", stringRepr: "Error: Thrown at file_contains_iframe.html" },
+ { type: "Error", stringRepr: "Error: Thrown at file_contains_img.html" },
+ ],
+ },
+ {
+ title: "custom error",
+ scriptingParams: {
+ target: { tabId },
+ func: () => {
+ class CustomError extends Error {
+ constructor(message) {
+ super(message);
+
+ this.name = 'CustomError';
+ }
+ }
+
+ throw new CustomError("a custom error message");
+ },
+ },
+ // See Bug 1556604 for why a custom (derived) error looks like a
+ // normal error object after cloning.
+ expectedErrors: [
+ { type: "Error", stringRepr: "Error: a custom error message" },
+ ],
+ },
+ {
+ title: "promise rejection with a string value",
+ scriptingParams: {
+ target: { tabId },
+ func: () => {
+ // eslint-disable-next-line no-throw-literal
+ throw 'an error message';
+ },
+ },
+ expectedErrors: [
+ { type: "String", stringRepr: "an error message" },
+ ],
+ },
+ {
+ title: "promise rejection with an error",
+ scriptingParams: {
+ target: { tabId },
+ func: () => {
+ throw new Error('ooops');
+ },
+ },
+ expectedErrors: [
+ { type: "Error", stringRepr: "Error: ooops" },
+ ],
+ },
+ {
+ title: "promise rejection with null",
+ scriptingParams: {
+ target: { tabId },
+ func: () => {
+ throw null; // eslint-disable-line no-throw-literal
+ },
+ },
+ expectedErrors: [
+ // This means we would receive `error: null`.
+ { type: "Null", stringRepr: "null" },
+ ],
+ },
+ {
+ title: "promise rejection with undefined",
+ scriptingParams: {
+ target: { tabId },
+ func: () => {
+ return new Promise((resolve, reject) => {
+ reject(undefined);
+ });
+ },
+ },
+ expectedErrors: [
+ // This means we would receive `error: undefined`.
+ { type: "Undefined", stringRepr: "undefined" },
+ ],
+ },
+ {
+ title: "promise rejection with empty string",
+ scriptingParams: {
+ target: { tabId },
+ func: () => {
+ throw ""; // eslint-disable-line no-throw-literal
+ },
+ },
+ expectedErrors: [
+ { type: "String", stringRepr: "" },
+ ],
+ },
+ {
+ title: "promise rejection with zero",
+ scriptingParams: {
+ target: { tabId },
+ func: () => {
+ throw 0; // eslint-disable-line no-throw-literal
+ },
+ },
+ expectedErrors: [
+ { type: "Number", stringRepr: "0" },
+ ],
+ },
+ {
+ title: "promise rejection with false",
+ scriptingParams: {
+ target: { tabId },
+ func: () => {
+ throw false; // eslint-disable-line no-throw-literal
+ },
+ },
+ expectedErrors: [
+ { type: "Boolean", stringRepr: "false" },
+ ],
+ },
+ ];
+
+ for (const { title, scriptingParams, expectedErrors } of TEST_CASES) {
+ const results = await browser.scripting.executeScript(scriptingParams);
+ // Sort injection results by frameId to always assert the results in
+ // the same order.
+ results.sort((a, b) => a.frameId - b.frameId);
+
+ browser.test.assertEq(
+ expectedErrors.length,
+ results.length,
+ `expected ${expectedErrors.length} results`
+ );
+
+ for (const [i, { type, stringRepr }] of expectedErrors.entries()) {
+ browser.test.assertTrue(
+ "error" in results[i],
+ `${title} - expected error property to be set`
+ );
+ browser.test.assertFalse(
+ "result" in results[i],
+ `${title} - expected result property to be unset`
+ );
+
+ const { frameId, error } = results[i];
+
+ browser.test.assertEq(
+ `[object ${type}]`,
+ Object.prototype.toString.call(error),
+ `${title} - expected instance of ${type} - ${frameId}`
+ );
+ browser.test.assertEq(
+ stringRepr,
+ String(error),
+ `${title} - got expected errors - ${frameId}`
+ );
+ }
+ }
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html",
+ true
+ );
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+
+ await AppTestDelegate.removeTab(window, tab);
+});
+
+add_task(
+ async function test_executeScript_with_allFrames_and_wrong_host_permissions() {
+ let extension = makeExtension({
+ manifest: {
+ host_permissions: MOCHITEST_HOST_PERMISSIONS,
+ permissions: ["scripting", "webNavigation"],
+ },
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const tabId = tabs[0].id;
+ const frames = await browser.webNavigation.getAllFrames({ tabId });
+ // 1. Top-level frame with the MochiTest runner
+ // 2. Frame for this file
+ // 3. Frame that loads `file_sample.html` at the top of this file
+ browser.test.assertEq(3, frames.length, "expected 3 frames");
+
+ const results = await browser.scripting.executeScript({
+ target: { tabId, allFrames: true },
+ func: () => {},
+ });
+
+ browser.test.assertEq(
+ 2,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertTrue(
+ typeof results[0].error === "undefined",
+ "expected no error in results[0]"
+ );
+ browser.test.assertTrue(
+ typeof results[1].error === "undefined",
+ "expected no error in results[1]"
+ );
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+ }
+);
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_activeTab.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_activeTab.html
new file mode 100644
index 0000000000..5eb2193409
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_activeTab.html
@@ -0,0 +1,144 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Tests scripting.executeScript() and activeTab</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+
+"use strict";
+
+const makeExtension = ({ manifest: manifestProps, ...otherProps }) => {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ action: {},
+ ...manifestProps,
+ },
+ ...otherProps,
+ });
+};
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", true]],
+ });
+});
+
+async function verifyExecuteScriptActiveTab(permissions, host_permissions) {
+ let extension = makeExtension({
+ manifest: {
+ permissions: ["scripting", ...permissions],
+ host_permissions,
+ },
+ background() {
+ browser.action.onClicked.addListener(async tab => {
+ const results = await browser.scripting.executeScript({
+ target: { tabId: tab.id },
+ func: () => document.title,
+ });
+
+ browser.test.assertEq(
+ 1,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(
+ "file sample",
+ results[0].result,
+ "got the expected title"
+ );
+ browser.test.assertEq(
+ 0,
+ results[0].frameId,
+ "got the expected frameId"
+ );
+
+ browser.test.sendMessage("execute-script");
+ });
+
+ browser.test.onMessage.addListener(async msg => {
+ switch (msg) {
+ case "reload-and-execute":
+ const tabs = await browser.tabs.query({ active: true });
+ const tabId = tabs[0].id;
+
+ let promiseTabLoad = new Promise(resolve => {
+ browser.tabs.onUpdated.addListener(function listener(updatedTabId, changeInfo) {
+ browser.test.assertEq(tabId, updatedTabId, "got expected tabId");
+
+ if (tabId === updatedTabId && changeInfo.status === "complete") {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+
+ await browser.tabs.reload();
+ await promiseTabLoad;
+
+ await browser.test.assertRejects(
+ browser.scripting.executeScript({
+ target: { tabId },
+ func: () => {
+ browser.test.fail("Unexpected execution");
+ },
+ }),
+ "Missing host permission for the tab",
+ "expected host permission error"
+ );
+
+ browser.test.sendMessage("execute-script-after-reload");
+
+ break;
+ default:
+ browser.test.fail(`invalid message received: ${msg}`);
+ }
+ });
+
+ browser.test.sendMessage("background-ready");
+ },
+ });
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html",
+ true
+ );
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ await AppTestDelegate.clickBrowserAction(window, extension);
+ await extension.awaitMessage("execute-script");
+ await AppTestDelegate.closeBrowserAction(window, extension);
+
+ extension.sendMessage("reload-and-execute");
+ await extension.awaitMessage("execute-script-after-reload");
+
+ await extension.unload();
+
+ await AppTestDelegate.removeTab(window, tab);
+}
+
+// Test executeScript works with the standard activeTab permission.
+add_task(async function test_executeScript_activeTab_permission() {
+ await verifyExecuteScriptActiveTab(["activeTab"], []);
+});
+
+// Test executeScript works with automatic activeTab granted from optional
+// host permissions.
+add_task(async function test_executeScript_activeTab_automatic_originControls() {
+ await verifyExecuteScriptActiveTab([], ["*://test1.example.com/*"]);
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_injectImmediately.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_injectImmediately.html
new file mode 100644
index 0000000000..9d05925adc
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_injectImmediately.html
@@ -0,0 +1,215 @@
+<!DOCTYPE HTML>
+ <html>
+<head>
+ <meta charset="utf-8">
+ <title>Tests scripting.executeScript() and injectImmediately</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+
+"use strict";
+
+const MOCHITEST_HOST_PERMISSIONS = [
+ "*://mochi.test/",
+ "*://mochi.xorigin-test/",
+ "*://test1.example.com/",
+];
+
+const makeExtension = ({ manifest: manifestProps, ...otherProps }) => {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["scripting"],
+ host_permissions: [
+ ...MOCHITEST_HOST_PERMISSIONS,
+ // Used in `file_contains_iframe.html`
+ "https://example.org/",
+ ],
+ granted_host_permissions: true,
+ ...manifestProps,
+ },
+ useAddonManager: "temporary",
+ ...otherProps,
+ });
+};
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", true]],
+ });
+});
+
+add_task(async function test_executeScript_injectImmediately() {
+ let extension = makeExtension({
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ const tabId = tabs[0].id;
+
+ let onUpdatedPromise = (tabId, url, status) => {
+ return new Promise(resolve => {
+ browser.tabs.onUpdated.addListener(function listener(_, changed, tab) {
+ if (tabId == tab.id && changed.status == status && tab.url == url) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+ };
+
+ const url = [
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/",
+ `file_slowed_document.sjs?with-iframe&r=${Math.random()}`,
+ ].join("");
+ const loadingPromise = onUpdatedPromise(tabId, url, "loading");
+ const completePromise = onUpdatedPromise(tabId, url, "complete");
+
+ await browser.tabs.update(tabId, { url });
+ await loadingPromise;
+
+ const func = () => {
+ window.counter = (window.counter || 0) + 1;
+
+ return window.counter;
+ };
+
+ let results = await Promise.all([
+ // counter = 1
+ browser.scripting.executeScript({
+ target: { tabId },
+ func,
+ injectImmediately: true,
+ }),
+ // counter = 3
+ browser.scripting.executeScript({
+ target: { tabId },
+ func,
+ injectImmediately: false,
+ }),
+ // counter = 4
+ browser.scripting.executeScript({
+ target: { tabId },
+ func,
+ // `injectImmediately` is `false` by default
+ }),
+ // counter = 2
+ browser.scripting.executeScript({
+ target: { tabId },
+ func,
+ injectImmediately: true,
+ }),
+ // counter = 5
+ browser.scripting.executeScript({
+ target: { tabId },
+ func,
+ injectImmediately: false,
+ }),
+ ]);
+ browser.test.assertEq(
+ 5,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(
+ "1 3 4 2 5",
+ results.map(res => res[0].result).join(" "),
+ `got expected results: ${JSON.stringify(results)}`
+ );
+
+ await completePromise;
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/",
+ true
+ );
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+
+ await AppTestDelegate.removeTab(window, tab);
+});
+
+add_task(async function test_executeScript_injectImmediately_after_document_idle() {
+ let extension = makeExtension({
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const tabId = tabs[0].id;
+
+ const func = () => {
+ window.counter = (window.counter || 0) + 1;
+
+ return window.counter;
+ };
+
+ let results = await Promise.all([
+ browser.scripting.executeScript({
+ target: { tabId },
+ func,
+ injectImmediately: true,
+ }),
+ browser.scripting.executeScript({
+ target: { tabId },
+ func,
+ injectImmediately: false,
+ }),
+ browser.scripting.executeScript({
+ target: { tabId },
+ func,
+ // `injectImmediately` is `false` by default
+ }),
+ browser.scripting.executeScript({
+ target: { tabId },
+ func,
+ injectImmediately: true,
+ }),
+ browser.scripting.executeScript({
+ target: { tabId },
+ func,
+ injectImmediately: false,
+ }),
+ ]);
+ browser.test.assertEq(
+ 5,
+ results.length,
+ "got expected number of results"
+ );
+ browser.test.assertEq(
+ "1 2 3 4 5",
+ results.map(res => res[0].result).join(" "),
+ `got expected results: ${JSON.stringify(results)}`
+ );
+
+ browser.test.notifyPass("execute-script");
+ },
+ });
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html",
+ true
+ );
+
+ await extension.startup();
+ await extension.awaitFinish("execute-script");
+ await extension.unload();
+
+ await AppTestDelegate.removeTab(window, tab);
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_insertCSS.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_insertCSS.html
new file mode 100644
index 0000000000..3e2cef8721
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_insertCSS.html
@@ -0,0 +1,395 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Tests scripting.insertCSS()</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const MOCHITEST_HOST_PERMISSIONS = [
+ "*://mochi.test/",
+ "*://mochi.xorigin-test/",
+ "*://test1.example.com/",
+];
+
+const makeExtension = ({ manifest: manifestProps, ...otherProps }) => {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["scripting"],
+ host_permissions: [
+ ...MOCHITEST_HOST_PERMISSIONS,
+ // Used in `file_contains_iframe.html`
+ "https://example.org/",
+ ],
+ granted_host_permissions: true,
+ ...manifestProps,
+ },
+ useAddonManager: "temporary",
+ ...otherProps,
+ });
+};
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", true]],
+ });
+});
+
+add_task(async function test_insertCSS_and_removeCSS_params_validation() {
+ let extension = makeExtension({
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+
+ const TEST_CASES = [
+ {
+ title: "no files and no css",
+ cssParams: {},
+ expectedError: "Exactly one of files and css must be specified.",
+ },
+ {
+ title: "both files and css are passed",
+ cssParams: {
+ files: ["styles.css"],
+ css: "* { background: rgb(1, 1, 1) }",
+ },
+ expectedError: "Exactly one of files and css must be specified.",
+ },
+ {
+ title: "both allFrames and frameIds are passed",
+ cssParams: {
+ target: {
+ tabId: tabs[0].id,
+ allFrames: true,
+ frameIds: [1, 2, 3],
+ },
+ files: ["styles.css"],
+ },
+ expectedError: "Cannot specify both 'allFrames' and 'frameIds'.",
+ },
+ {
+ title: "empty css string with a file",
+ cssParams: {
+ css: "",
+ files: ["styles.css"],
+ },
+ expectedError: "Exactly one of files and css must be specified.",
+ },
+ ];
+
+ for (const { title, cssParams, expectedError } of TEST_CASES) {
+ await browser.test.assertRejects(
+ browser.scripting.insertCSS({
+ target: { tabId: tabs[0].id },
+ ...cssParams,
+ }),
+ expectedError,
+ `${title} - expected error for insertCSS()`
+ );
+
+ await browser.test.assertRejects(
+ browser.scripting.removeCSS({
+ target: { tabId: tabs[0].id },
+ ...cssParams,
+ }),
+ expectedError,
+ `${title} - expected error for removeCSS()`
+ );
+ }
+
+ browser.test.notifyPass("checks-done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("checks-done");
+ await extension.unload();
+});
+
+add_task(async function test_insertCSS_with_invalid_tabId() {
+ let extension = makeExtension({
+ async background() {
+ // This tab ID should not exist.
+ const tabId = 123456789;
+
+ await browser.test.assertRejects(
+ browser.scripting.insertCSS({
+ target: { tabId },
+ css: "* { background: rgb(1, 1, 1) }",
+ }),
+ `Invalid tab ID: ${tabId}`
+ );
+
+ browser.test.notifyPass("insert-css");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("insert-css");
+ await extension.unload();
+});
+
+add_task(async function test_insertCSS_with_wrong_host_permissions() {
+ let extension = makeExtension({
+ manifest: {
+ host_permissions: [],
+ },
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ browser.test.assertRejects(
+ browser.scripting.insertCSS({
+ target: { tabId: tabs[0].id },
+ css: "* { background: rgb(1, 1, 1) }",
+ }),
+ /Missing host permission for the tab/,
+ "expected host permission error"
+ );
+
+ browser.test.notifyPass("insert-css");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("insert-css");
+ await extension.unload();
+});
+
+add_task(async function test_insertCSS_and_removeCSS() {
+ let extension = makeExtension({
+ manifest: {
+ permissions: ["scripting", "webNavigation"],
+ },
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const tabId = tabs[0].id;
+
+ const frames = await browser.webNavigation.getAllFrames({ tabId });
+ // 1. Top-level frame that loads `file_contains_iframe.html`
+ // 2. Frame that loads `file_contains_img.html`
+ browser.test.assertEq(2, frames.length, "expected 2 frames");
+ const frameIds = frames.map(frame => frame.frameId);
+
+ const cssColor1 = "rgb(1, 1, 1)";
+ const cssColor2 = "rgb(2, 2, 2)";
+ const cssColorInFile1 = "rgb(3, 3, 3)";
+ const defaultColor = "rgba(0, 0, 0, 0)";
+
+ const TEST_CASES = [
+ {
+ title: "with css prop",
+ elementId: "div-1",
+ cssParams: [
+ {
+ target: { tabId },
+ css: `#div-1 { background: ${cssColor1} }`,
+ },
+ ],
+ expectedResults: [cssColor1, defaultColor],
+ },
+ {
+ title: "with a file",
+ elementId: "div-2",
+ cssParams: [
+ {
+ target: { tabId },
+ files: ["file1.css"],
+ },
+ ],
+ expectedResults: [cssColorInFile1, defaultColor],
+ },
+ {
+ title: "css prop in a single frame",
+ elementId: "div-3",
+ cssParams: [
+ {
+ target: { tabId, frameIds: [frameIds[0]] },
+ css: `#div-3 { background: ${cssColor2} }`,
+ },
+ ],
+ expectedResults: [cssColor2, defaultColor],
+ },
+ {
+ title: "css prop in multiple frames",
+ elementId: "div-4",
+ cssParams: [
+ {
+ target: { tabId, frameIds },
+ css: `#div-4 { background: ${cssColor1} }`,
+ },
+ ],
+ expectedResults: [cssColor1, cssColor1],
+ },
+ {
+ title: "allFrames is true",
+ elementId: "div-5",
+ cssParams: [
+ {
+ target: { tabId, allFrames: true },
+ css: `#div-5 { background: ${defaultColor} }`,
+ },
+ ],
+ expectedResults: [defaultColor, defaultColor],
+ },
+ {
+ title: "origin: 'AUTHOR'",
+ elementId: "div-6",
+ cssParams: [
+ {
+ target: { tabId },
+ css: `#div-6 { background: ${cssColor1} }`,
+ origin: "AUTHOR",
+ },
+ {
+ target: { tabId },
+ css: `#div-6 { background: ${cssColor2} }`,
+ origin: "AUTHOR",
+ },
+ ],
+ expectedResults: [cssColor2, defaultColor],
+ },
+ {
+ title: "origin: 'USER'",
+ elementId: "div-7",
+ cssParams: [
+ {
+ target: { tabId },
+ css: `#div-7 { background: ${cssColor1} !important }`,
+ origin: "USER",
+ },
+ {
+ target: { tabId },
+ css: `#div-7 { background: ${cssColor2} !important }`,
+ origin: "AUTHOR",
+ },
+ ],
+ // User has higher importance.
+ expectedResults: [cssColor1, defaultColor],
+ },
+ {
+ title: "empty css string",
+ elementId: "div-8",
+ cssParams: [
+ {
+ target: { tabId },
+ css: "",
+ },
+ ],
+ expectedResults: [defaultColor, defaultColor],
+ },
+ {
+ title: "allFrames is false",
+ elementId: "div-9",
+ cssParams: [
+ {
+ target: { tabId, allFrames: false },
+ css: `#div-9 { background: ${cssColor1} }`,
+ },
+ ],
+ expectedResults: [cssColor1, defaultColor],
+ },
+ ];
+
+ const getBackgroundColor = elementId => {
+ return window.getComputedStyle(document.getElementById(elementId))
+ .backgroundColor;
+ };
+
+ for (const {
+ title,
+ elementId,
+ cssParams,
+ expectedResults,
+ } of TEST_CASES) {
+ // Create a unique element for the current test case.
+ await browser.scripting.executeScript({
+ target: { tabId, allFrames: true },
+ func: elementId => {
+ const element = document.createElement("div");
+ element.setAttribute("id", elementId);
+ document.body.appendChild(element);
+ },
+ args: [elementId],
+ });
+
+ for (const params of cssParams) {
+ const result = await browser.scripting.insertCSS(params);
+ // `insertCSS()` should not resolve to a value.
+ browser.test.assertEq(undefined, result, "got expected empty result");
+ }
+
+ let results = await browser.scripting.executeScript({
+ target: { tabId, allFrames: true },
+ func: getBackgroundColor,
+ args: [elementId],
+ });
+ results.sort((a, b) => a.frameId - b.frameId);
+
+ browser.test.assertEq(
+ expectedResults.length,
+ results.length,
+ `${title} - got the expected number of results`
+ );
+ results.forEach((result, index) => {
+ browser.test.assertEq(
+ expectedResults[index],
+ result.result,
+ `${title} - got expected result (index=${index}): ${title}`
+ );
+ });
+
+ results = await Promise.all(
+ cssParams.map(params => browser.scripting.removeCSS(params))
+ );
+ // `removeCSS()` should not resolve to a value.
+ results.forEach(result => {
+ browser.test.assertEq(undefined, result, "got expected empty result");
+ });
+
+ results = await browser.scripting.executeScript({
+ target: { tabId, allFrames: true },
+ func: getBackgroundColor,
+ args: [elementId],
+ });
+
+ browser.test.assertTrue(
+ results.every(({ result }) => result === defaultColor),
+ "got expected default color in all frames"
+ );
+ }
+
+ browser.test.notifyPass("insert-and-remove-css");
+ },
+ files: {
+ "file1.css": "#div-2 { background: rgb(3, 3, 3) }",
+ },
+ });
+
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://test1.example.com/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html",
+ true
+ );
+
+ await extension.startup();
+ await extension.awaitFinish("insert-and-remove-css");
+ await extension.unload();
+
+ await AppTestDelegate.removeTab(window, tab);
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_permissions.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_permissions.html
new file mode 100644
index 0000000000..e3e6552290
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_permissions.html
@@ -0,0 +1,149 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Tests scripting APIs and permissions</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+
+"use strict";
+
+const verifyRegisterContentScripts = async ({ manifest_version }) => {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ permissions: ["scripting"],
+ host_permissions: ["*://example.com/*"],
+ optional_permissions: ["*://example.org/*"],
+
+ },
+ async background() {
+ browser.test.onMessage.addListener(async (msg, value) => {
+ switch (msg) {
+ case "grant-permission":
+ let granted = await new Promise(resolve => {
+ browser.test.withHandlingUserInput(() => {
+ resolve(browser.permissions.request(value));
+ });
+ });
+ browser.test.assertTrue(granted, "permission request succeeded");
+ browser.test.sendMessage("permission-granted");
+ break;
+
+ default:
+ browser.test.fail(`invalid message received: ${msg}`);
+ }
+ });
+
+ await browser.scripting.registerContentScripts([
+ {
+ id: "script",
+ js: ["script.js"],
+ matches: [
+ "*://example.com/*",
+ "*://example.net/*",
+ "*://example.org/*",
+ ],
+ persistAcrossSessions: false,
+ },
+ ]);
+
+ browser.test.sendMessage("background-ready");
+ },
+ files: {
+ "script.js": () => {
+ browser.test.sendMessage(
+ "script-ran",
+ window.location.host + window.location.search
+ );
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ if (manifest_version > 2) {
+ extension.sendMessage("grant-permission", {
+ origins: ["*://example.com/*"],
+ });
+ await extension.awaitMessage("permission-granted");
+ }
+
+ // `example.net` is not declared in the list of `permissions`.
+ let tabExampleNet = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://example.net/",
+ true
+ );
+ // `example.org` is listed in `optional_permissions`.
+ let tabExampleOrg = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://example.org/",
+ true
+ );
+ // `example.com` is listed in `permissions`.
+ let tabExampleCom = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://example.com/",
+ true
+ );
+
+ let value = await extension.awaitMessage("script-ran");
+ ok(
+ value === "example.com",
+ `expected: example.com, received: ${value}`
+ );
+
+ extension.sendMessage("grant-permission", {
+ origins: ["*://example.org/*"],
+ });
+ await extension.awaitMessage("permission-granted");
+
+ let tabExampleOrg2 = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "https://example.org/?2",
+ true
+ );
+
+ value = await extension.awaitMessage("script-ran");
+ ok(
+ value === "example.org?2",
+ `expected: example.org?2, received: ${value}`
+ );
+
+ await AppTestDelegate.removeTab(window, tabExampleNet);
+ await AppTestDelegate.removeTab(window, tabExampleOrg);
+ await AppTestDelegate.removeTab(window, tabExampleCom);
+ await AppTestDelegate.removeTab(window, tabExampleOrg2);
+
+ await extension.unload();
+};
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.manifestV3.enabled", true],
+ ["extensions.webextOptionalPermissionPrompts", false],
+ ],
+ });
+});
+
+add_task(async function test_scripting_registerContentScripts_mv2() {
+ await verifyRegisterContentScripts({ manifest_version: 2 });
+});
+
+add_task(async function test_scripting_registerContentScripts_mv3() {
+ await verifyRegisterContentScripts({ manifest_version: 3 });
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_removeCSS.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_removeCSS.html
new file mode 100644
index 0000000000..3036e49761
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_removeCSS.html
@@ -0,0 +1,135 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Tests scripting.removeCSS()</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const MOCHITEST_HOST_PERMISSIONS = [
+ "*://mochi.test/",
+ "*://mochi.xorigin-test/",
+ "*://test1.example.com/",
+];
+
+const makeExtension = ({ manifest: manifestProps, ...otherProps }) => {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["scripting"],
+ host_permissions: [...MOCHITEST_HOST_PERMISSIONS],
+ granted_host_permissions: true,
+ ...manifestProps,
+ },
+ useAddonManager: "temporary",
+ ...otherProps,
+ });
+};
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", true]],
+ });
+});
+
+add_task(async function test_removeCSS_with_invalid_tabId() {
+ let extension = makeExtension({
+ async background() {
+ // This tab ID should not exist.
+ const tabId = 123456789;
+
+ await browser.test.assertRejects(
+ browser.scripting.removeCSS({
+ target: { tabId },
+ css: "* { background: rgb(42, 42, 42) }",
+ }),
+ `Invalid tab ID: ${tabId}`
+ );
+
+ browser.test.notifyPass("remove-css");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("remove-css");
+ await extension.unload();
+});
+
+add_task(async function test_removeCSS_without_insertCSS_called_before() {
+ let extension = makeExtension({
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ browser.scripting
+ .removeCSS({
+ target: { tabId: tabs[0].id },
+ css: "* { background: rgb(42, 42, 42) }",
+ })
+ .then(() => {
+ browser.test.notifyPass("remove-css");
+ })
+ .catch(() => {
+ browser.test.notifyFail("remove-css");
+ });
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("remove-css");
+ await extension.unload();
+});
+
+add_task(async function test_removeCSS_with_origin_mismatch() {
+ let extension = makeExtension({
+ async background() {
+ const tabs = await browser.tabs.query({ active: true });
+ browser.test.assertEq(1, tabs.length, "expected 1 tab");
+
+ const cssColor = "rgb(42, 42, 42)";
+ const cssParams = {
+ target: { tabId: tabs[0].id },
+ css: `* { background: ${cssColor} !important }`,
+ };
+
+ await browser.scripting.insertCSS({ ...cssParams, origin: "AUTHOR" });
+
+ let results = await browser.scripting.executeScript({
+ target: { tabId: tabs[0].id },
+ func: () => {
+ return window.getComputedStyle(document.body).backgroundColor;
+ },
+ });
+
+ browser.test.assertEq(cssColor, results[0].result, "got expected color");
+
+ // Here, we pass a different origin, which should result in no CSS
+ // removal.
+ await browser.scripting.removeCSS({ ...cssParams, origin: "USER" });
+
+ browser.test.assertEq(
+ cssColor,
+ results[0].result,
+ "got expected color after removeCSS"
+ );
+
+ browser.test.notifyPass("remove-css");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("remove-css");
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html
new file mode 100644
index 0000000000..ffdbc90efb
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html
@@ -0,0 +1,100 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function background() {
+ // Add two listeners that both send replies. We're supposed to ignore all but one
+ // of them. Which one is chosen is non-deterministic.
+
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct");
+
+ if (msg == "getreply") {
+ sendReply("reply1");
+ }
+ });
+
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct");
+
+ if (msg == "getreply") {
+ sendReply("reply2");
+ }
+ });
+
+ function sleep(callback, n = 10) {
+ if (n == 0) {
+ callback();
+ } else {
+ setTimeout(function() { sleep(callback, n - 1); }, 0);
+ }
+ }
+
+ let done_count = 0;
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct");
+
+ if (msg == "done") {
+ done_count++;
+ browser.test.assertEq(done_count, 1, "got exactly one reply");
+
+ // Go through the event loop a few times to make sure we don't get multiple replies.
+ sleep(function() {
+ browser.test.notifyPass("sendmessage_doublereply");
+ });
+ }
+ });
+}
+
+function contentScript() {
+ browser.runtime.sendMessage("getreply", function(resp) {
+ if (resp != "reply1" && resp != "reply2") {
+ return; // test failed
+ }
+ browser.runtime.sendMessage("done");
+ });
+}
+
+let extensionData = {
+ background,
+ manifest: {
+ "permissions": ["tabs"],
+ "content_scripts": [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ }],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+};
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+
+ await Promise.all([waitForLoad(win), extension.awaitFinish("sendmessage_doublereply")]);
+
+ win.close();
+
+ await extension.unload();
+ info("extension unloaded");
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html
new file mode 100644
index 0000000000..6b42073031
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html
@@ -0,0 +1,45 @@
+<!doctype html>
+<head>
+ <title>Test sendMessage frameId</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<script>
+"use strict";
+
+add_task(async function test_sendMessage_frameId() {
+ const html = `<!doctype html><meta charset="utf-8"><script src="script.js"><\/script>`;
+
+ const extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ browser.test.sendMessage(msg, sender);
+ });
+ browser.tabs.create({url: "tab.html"});
+ },
+ files: {
+ "iframe.html": html,
+ "tab.html": `${html}<iframe src="iframe.html"></iframe>`,
+ "script.js": () => {
+ browser.runtime.sendMessage(window.top === window ? "tab" : "iframe");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ const tab = await extension.awaitMessage("tab");
+ ok(tab.url.endsWith("tab.html"), "Got the message from the tab");
+ is(tab.frameId, 0, "And sender.frameId is zero");
+
+ const iframe = await extension.awaitMessage("iframe");
+ ok(iframe.url.endsWith("iframe.html"), "Got the message from the iframe");
+ is(typeof iframe.frameId, "number", "With sender.frameId of type number");
+ ok(iframe.frameId > 0, "And sender.frameId greater than zero");
+
+ await extension.unload();
+});
+
+</script>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html
new file mode 100644
index 0000000000..a18b003e48
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html
@@ -0,0 +1,115 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<script>
+"use strict";
+
+async function testFn(expectPromise) {
+ browser.test.assertEq(null, chrome.runtime.lastError, "no lastError before call");
+ let retval = chrome.runtime.sendMessage("msg");
+ browser.test.assertEq(null, chrome.runtime.lastError, "no lastError after call");
+ if (expectPromise) {
+ browser.test.assertTrue(retval instanceof Promise, "chrome.runtime.sendMessage should return a promise");
+ } else {
+ browser.test.assertEq(undefined, retval, "return value of chrome.runtime.sendMessage with callback");
+ }
+
+ let isAsyncCall = false;
+ retval = chrome.runtime.sendMessage("msg", reply => {
+ browser.test.assertEq(undefined, reply, "no reply");
+ browser.test.assertTrue(isAsyncCall, "chrome.runtime.sendMessage's callback must be called asynchronously");
+ browser.test.assertEq(undefined, retval, "return value of chrome.runtime.sendMessage with callback");
+ browser.test.assertEq("Could not establish connection. Receiving end does not exist.", chrome.runtime.lastError.message);
+ browser.test.sendMessage("finished", retval);
+ });
+ isAsyncCall = true;
+}
+
+add_task(async function test_content_script_sendMessage_without_listener() {
+ async function contentScript() {
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage("msg"),
+ "Could not establish connection. Receiving end does not exist.");
+
+ browser.test.notifyPass("sendMessage callback was invoked");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [{
+ js: ["contentscript.js"],
+ matches: ["http://mochi.test/*/file_sample.html"],
+ }],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+ await extension.awaitFinish("sendMessage callback was invoked");
+ win.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_content_script_chrome_sendMessage_without_listener() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 2,
+ content_scripts: [{
+ js: ["contentscript.js"],
+ matches: ["http://mochi.test/*/file_sample.html"],
+ }],
+ },
+ // In MV2, chrome namespace in content scripts do get promises, however in background pages they do not.
+ background: `(${testFn})(false)`,
+ files: {
+ "contentscript.js": `(${testFn})(true)`,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished");
+
+ let win = window.open("file_sample.html");
+ await extension.awaitMessage("finished");
+ win.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_chrome_sendMessage_without_listener_v3() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", true]],
+ });
+
+ // We only test the background here because content script behavior
+ // is independant of the manifest version.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ },
+ background: `(${testFn})(true)`,
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("finished");
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html
new file mode 100644
index 0000000000..a7f6314efd
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html
@@ -0,0 +1,78 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function background() {
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct");
+
+ if (msg == 0) {
+ sendReply("reply1");
+ } else if (msg == 1) {
+ window.setTimeout(function() {
+ sendReply("reply2");
+ }, 0);
+ return true;
+ } else if (msg == 2) {
+ browser.test.notifyPass("sendmessage_reply");
+ }
+ });
+}
+
+function contentScript() {
+ browser.runtime.sendMessage(0, function(resp1) {
+ if (resp1 != "reply1") {
+ return; // test failed
+ }
+ browser.runtime.sendMessage(1, function(resp2) {
+ if (resp2 != "reply2") {
+ return; // test failed
+ }
+ browser.runtime.sendMessage(2);
+ });
+ });
+}
+
+let extensionData = {
+ background,
+ manifest: {
+ "permissions": ["tabs"],
+ "content_scripts": [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_idle",
+ }],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+};
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+
+ await Promise.all([waitForLoad(win), extension.awaitFinish("sendmessage_reply")]);
+
+ win.close();
+
+ await extension.unload();
+ info("extension unloaded");
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html
new file mode 100644
index 0000000000..8cce833b49
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html
@@ -0,0 +1,202 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function backgroundScript(token, id, otherId) {
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertEq(id, sender.id, `${id}: Got expected sender ID`);
+
+ if (msg === `content-${token}`) {
+ browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"),
+ `${id}: sender url correct`);
+
+ let tabId = sender.tab.id;
+ browser.tabs.sendMessage(tabId, `${token}-contentMessage`);
+
+ sendReply(`${token}-done`);
+ } else if (msg === `tab-${token}`) {
+ browser.runtime.sendMessage(otherId, `${otherId}-tabMessage`);
+ browser.runtime.sendMessage(`${token}-tabMessage`);
+
+ sendReply(`${token}-done`);
+ } else {
+ browser.test.fail(`${id}: Unexpected runtime message received: ${msg} ${uneval(sender)}`);
+ }
+ });
+
+ browser.runtime.onMessageExternal.addListener((msg, sender, sendReply) => {
+ browser.test.assertEq(otherId, sender.id, `${id}: Got expected external sender ID`);
+
+ if (msg === `content-${id}`) {
+ browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"),
+ `${id}: external sender url correct`);
+
+ sendReply(`${otherId}-done`);
+ } else if (msg === `tab-${id}`) {
+ sendReply(`${otherId}-done`);
+ } else if (msg !== `${id}-tabMessage`) {
+ browser.test.fail(`${id}: Unexpected runtime external message received: ${msg} ${uneval(sender)}`);
+ }
+ });
+
+ browser.tabs.create({url: "tab.html"});
+}
+
+function contentScript(token, id, otherId) {
+ let gotContentMessage = false;
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertEq(id, sender.id, `${id}: Got expected sender ID`);
+
+ browser.test.assertEq(`${token}-contentMessage`, msg,
+ `${id}: Correct content script message`);
+ if (msg === `${token}-contentMessage`) {
+ gotContentMessage = true;
+ }
+ });
+
+ Promise.all([
+ browser.runtime.sendMessage(otherId, `content-${otherId}`).then(resp => {
+ browser.test.assertEq(`${id}-done`, resp, `${id}: Correct content script external response token`);
+ }),
+
+ browser.runtime.sendMessage(`content-${token}`).then(resp => {
+ browser.test.assertEq(`${token}-done`, resp, `${id}: Correct content script response token`);
+ }).catch(e => {
+ browser.test.fail(`content-${token} rejected with ${e.message}`);
+ }),
+ ]).then(() => {
+ browser.test.assertTrue(gotContentMessage, `${id}: Got content script message`);
+
+ browser.test.sendMessage("content-script-done");
+ });
+}
+
+async function tabScript(token, id, otherId) {
+ let gotTabMessage = false;
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertEq(id, sender.id, `${id}: Got expected sender ID`);
+
+ if (String(msg).startsWith("content-")) {
+ return;
+ }
+
+ browser.test.assertEq(`${token}-tabMessage`, msg,
+ `${id}: Correct tab script message`);
+ if (msg === `${token}-tabMessage`) {
+ gotTabMessage = true;
+ }
+ });
+
+ browser.test.sendMessage("tab-script-loaded");
+
+ await new Promise(resolve => {
+ const listener = (msg) => {
+ if (msg !== "run-tab-script") {
+ return;
+ }
+ browser.test.onMessage.removeListener(listener);
+ resolve();
+ };
+ browser.test.onMessage.addListener(listener);
+ });
+
+ Promise.all([
+ browser.runtime.sendMessage(otherId, `tab-${otherId}`).then(resp => {
+ browser.test.assertEq(`${id}-done`, resp, `${id}: Correct tab script external response token`);
+ }),
+
+ browser.runtime.sendMessage(`tab-${token}`).then(resp => {
+ browser.test.assertEq(`${token}-done`, resp, `${id}: Correct tab script response token`);
+ }),
+ ]).then(() => {
+ browser.test.assertTrue(gotTabMessage, `${id}: Got tab script message`);
+
+ window.close();
+
+ browser.test.sendMessage("tab-script-done");
+ });
+}
+
+function makeExtension(id, otherId) {
+ let token = Math.random();
+
+ let args = `${token}, ${JSON.stringify(id)}, ${JSON.stringify(otherId)}`;
+
+ let extensionData = {
+ background: `(${backgroundScript})(${args})`,
+ manifest: {
+ "browser_specific_settings": {"gecko": {id}},
+
+ "permissions": ["tabs"],
+
+ "content_scripts": [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ }],
+ },
+
+ files: {
+ "tab.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="tab.js"><\/script>
+ </head>
+ </html>`,
+
+ "tab.js": `(${tabScript})(${args})`,
+
+ "content_script.js": `(${contentScript})(${args})`,
+ },
+ };
+ return extensionData;
+}
+
+add_task(async function test_contentscript() {
+ const ID1 = "sendmessage1@mochitest.mozilla.org";
+ const ID2 = "sendmessage2@mochitest.mozilla.org";
+
+ let extension1 = ExtensionTestUtils.loadExtension(makeExtension(ID1, ID2));
+ let extension2 = ExtensionTestUtils.loadExtension(makeExtension(ID2, ID1));
+
+ await Promise.all([
+ extension1.startup(),
+ extension2.startup(),
+ extension1.awaitMessage("tab-script-loaded"),
+ extension2.awaitMessage("tab-script-loaded"),
+ ]);
+
+ extension1.sendMessage("run-tab-script");
+ extension2.sendMessage("run-tab-script");
+
+ let win = window.open("file_sample.html");
+
+ await waitForLoad(win);
+
+ await Promise.all([
+ extension1.awaitMessage("content-script-done"),
+ extension2.awaitMessage("content-script-done"),
+ extension1.awaitMessage("tab-script-done"),
+ extension2.awaitMessage("tab-script-done"),
+ ]);
+
+ win.close();
+
+ await extension1.unload();
+ await extension2.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html
new file mode 100644
index 0000000000..71ef3a2214
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html
@@ -0,0 +1,277 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const { ExtensionStorageIDB } = SpecialPowers.ChromeUtils.import(
+ "resource://gre/modules/ExtensionStorageIDB.jsm"
+);
+
+const storageTestHelpers = {
+ storageLocal: {
+ async writeData() {
+ await browser.storage.local.set({hello: "world"});
+ browser.test.sendMessage("finished");
+ },
+
+ async readData() {
+ const matchBrowserStorage = await browser.storage.local.get("hello").then(result => {
+ return (Object.keys(result).length == 1 && result.hello == "world");
+ });
+
+ browser.test.sendMessage("results", {matchBrowserStorage});
+ },
+
+ assertResults({results, keepOnUninstall}) {
+ if (keepOnUninstall) {
+ is(results.matchBrowserStorage, true, "browser.storage.local data is still present");
+ } else {
+ is(results.matchBrowserStorage, false, "browser.storage.local data was cleared");
+ }
+ },
+ },
+ storageSync: {
+ async writeData() {
+ await browser.storage.sync.set({hello: "world"});
+ browser.test.sendMessage("finished");
+ },
+
+ async readData() {
+ const matchBrowserStorage = await browser.storage.sync.get("hello").then(result => {
+ return (Object.keys(result).length == 1 && result.hello == "world");
+ });
+
+ browser.test.sendMessage("results", {matchBrowserStorage});
+ },
+
+ assertResults({results, keepOnUninstall}) {
+ if (keepOnUninstall) {
+ is(results.matchBrowserStorage, true, "browser.storage.sync data is still present");
+ } else {
+ is(results.matchBrowserStorage, false, "browser.storage.sync data was cleared");
+ }
+ },
+ },
+ webAPIs: {
+ async readData() {
+ let matchLocalStorage = (localStorage.getItem("hello") == "world");
+
+ let idbPromise = new Promise((resolve, reject) => {
+ let req = indexedDB.open("test");
+ req.onerror = e => {
+ reject(new Error(`indexedDB open failed with ${e.errorCode}`));
+ };
+
+ req.onupgradeneeded = e => {
+ // no database, data is not present
+ resolve(false);
+ };
+
+ req.onsuccess = e => {
+ let db = e.target.result;
+ let transaction = db.transaction("store", "readwrite");
+ let addreq = transaction.objectStore("store").get("hello");
+ addreq.onerror = addreqError => {
+ reject(new Error(`read from indexedDB failed with ${addreqError.errorCode}`));
+ };
+ addreq.onsuccess = () => {
+ let match = (addreq.result.value == "world");
+ resolve(match);
+ };
+ };
+ });
+
+ await idbPromise.then(matchIDB => {
+ let result = {matchLocalStorage, matchIDB};
+ browser.test.sendMessage("results", result);
+ });
+ },
+
+ async writeData() {
+ localStorage.setItem("hello", "world");
+
+ let idbPromise = new Promise((resolve, reject) => {
+ let req = indexedDB.open("test");
+ req.onerror = e => {
+ reject(new Error(`indexedDB open failed with ${e.errorCode}`));
+ };
+
+ req.onupgradeneeded = e => {
+ let db = e.target.result;
+ db.createObjectStore("store", {keyPath: "name"});
+ };
+
+ req.onsuccess = e => {
+ let db = e.target.result;
+ let transaction = db.transaction("store", "readwrite");
+ let addreq = transaction.objectStore("store")
+ .add({name: "hello", value: "world"});
+ addreq.onerror = addreqError => {
+ reject(new Error(`add to indexedDB failed with ${addreqError.errorCode}`));
+ };
+ addreq.onsuccess = () => {
+ resolve();
+ };
+ };
+ });
+
+ await idbPromise.then(() => {
+ browser.test.sendMessage("finished");
+ });
+ },
+
+ assertResults({results, keepOnUninstall}) {
+ if (keepOnUninstall) {
+ is(results.matchLocalStorage, true, "localStorage data is still present");
+ is(results.matchIDB, true, "indexedDB data is still present");
+ } else {
+ is(results.matchLocalStorage, false, "localStorage data was cleared");
+ is(results.matchIDB, false, "indexedDB data was cleared");
+ }
+ },
+ },
+};
+
+async function test_uninstall({extensionId, writeData, readData, assertResults}) {
+ // Set the pref to prevent cleaning up storage on uninstall in a separate prefEnv
+ // so we can pop it below, leaving flags set in the previous prefEnvs unmodified.
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webextensions.keepStorageOnUninstall", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: writeData,
+ manifest: {
+ browser_specific_settings: {gecko: {id: extensionId}},
+ permissions: ["storage"],
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished");
+ await extension.unload();
+
+ // Check that we can still see data we wrote to storage but clear the
+ // "leave storage" flag so our storaged gets cleared on the next uninstall.
+ // This effectively tests the keepUuidOnUninstall logic, which ensures
+ // that when we read storage again and check that it is cleared, that
+ // it is actually a meaningful test!
+ await SpecialPowers.popPrefEnv();
+
+ extension = ExtensionTestUtils.loadExtension({
+ background: readData,
+ manifest: {
+ browser_specific_settings: {gecko: {id: extensionId}},
+ permissions: ["storage"],
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ let results = await extension.awaitMessage("results");
+
+ assertResults({results, keepOnUninstall: true});
+
+ await extension.unload();
+
+ // Read again. This time, our data should be gone.
+ extension = ExtensionTestUtils.loadExtension({
+ background: readData,
+ manifest: {
+ browser_specific_settings: {gecko: {id: extensionId}},
+ permissions: ["storage"],
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ results = await extension.awaitMessage("results");
+
+ assertResults({results, keepOnUninstall: false});
+
+ await extension.unload();
+}
+
+
+add_task(async function test_setup_keep_uuid_on_uninstall() {
+ // Use a test-only pref to leave the addonid->uuid mapping around after
+ // uninstall so that we can re-attach to the same storage (this prefEnv
+ // is kept for this entire file and cleared automatically once all the
+ // tests in this file have been executed).
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webextensions.keepUuidOnUninstall", true]],
+ });
+});
+
+// Test extension indexedDB and localStorage storages get cleaned up when the
+// extension is uninstalled.
+add_task(async function test_uninstall_with_webapi_storages() {
+ await test_uninstall({
+ extensionId: "storage.cleanup-WebAPIStorages@tests.mozilla.org",
+ ...(storageTestHelpers.webAPIs),
+ });
+});
+
+// Test browser.storage.local with JSONFile backend gets cleaned up when the
+// extension is uninstalled.
+add_task(async function test_uninistall_with_storage_local_file_backend() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]],
+ });
+
+ await test_uninstall({
+ extensionId: "storage.cleanup-JSONFileBackend@tests.mozilla.org",
+ ...(storageTestHelpers.storageLocal),
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+// Repeat the cleanup test when the storage.local IndexedDB backend is enabled.
+add_task(async function test_uninistall_with_storage_local_idb_backend() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]],
+ });
+
+ await test_uninstall({
+ extensionId: "storage.cleanup-IDBBackend@tests.mozilla.org",
+ ...(storageTestHelpers.storageLocal),
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+// Legacy storage.sync backend is still being used on GeckoView builds.
+const storageSyncOldKintoBackend = SpecialPowers.Services.prefs.getBoolPref(
+ "webextensions.storage.sync.kinto",
+ false
+);
+
+// Verify browser.storage.sync rust backend is also cleared on uninstall.
+async function test_uninistall_with_storage_sync() {
+ await test_uninstall({
+ extensionId: "storage.cleanup-sync@tests.mozilla.org",
+ ...(storageTestHelpers.storageSync),
+ });
+}
+
+// NOTE: ideally we would be using a skip_if option on the add_task call,
+// but we don't support that in the add_task defined in mochitest-plain.
+if (!storageSyncOldKintoBackend) {
+ add_task(test_uninistall_with_storage_sync);
+}
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html
new file mode 100644
index 0000000000..135d2b9589
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html
@@ -0,0 +1,130 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test Storage API </title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ "set": [
+ ["dom.storageManager.enabled", true],
+ ["dom.storageManager.prompt.testing", true],
+ ["dom.storageManager.prompt.testing.allow", true],
+ ],
+ });
+});
+
+add_task(async function test_backgroundScript() {
+ function background() {
+ browser.test.assertTrue(navigator.storage !== undefined, "Has storage api interface");
+
+ // Test estimate.
+ browser.test.assertTrue("estimate" in navigator.storage, "Has estimate function");
+ browser.test.assertEq("function", typeof navigator.storage.estimate, "estimate is function");
+ browser.test.assertTrue(navigator.storage.estimate() instanceof Promise, "estimate returns a promise");
+
+ return browser.test.notifyPass("navigation_storage_api.done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("navigation_storage_api.done");
+ await extension.unload();
+});
+
+add_task(async function test_contentScript() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_first", false]],
+ });
+
+ function contentScript() {
+ // Should not access storage api in non-secure context.
+ browser.test.assertEq(undefined, navigator.storage,
+ "A page from the unsecure http protocol " +
+ "doesn't have access to the navigator.storage API");
+
+ return browser.test.notifyPass("navigation_storage_api.done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [{
+ "matches": ["http://example.com/*/file_sample.html"],
+ "js": ["content_script.js"],
+ }],
+ },
+
+ files: {
+ "content_script.js": `(${contentScript})()`,
+ },
+ });
+
+ await extension.startup();
+
+ // Open an explicit URL for testing Storage API in an insecure context.
+ let win = window.open("http://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+
+ await extension.awaitFinish("navigation_storage_api.done");
+
+ await extension.unload();
+ win.close();
+});
+
+add_task(async function test_contentScriptSecure() {
+ function contentScript() {
+ browser.test.assertTrue(navigator.storage !== undefined, "Has storage api interface");
+
+ // Test estimate.
+ browser.test.assertTrue("estimate" in navigator.storage, "Has estimate function");
+ browser.test.assertEq("function", typeof navigator.storage.estimate, "estimate is function");
+
+ // The promise that estimate function returns belongs to the content page,
+ // but the Promise constructor belongs to the content script sandbox.
+ // Check window.Promise here.
+ browser.test.assertTrue(navigator.storage.estimate() instanceof window.Promise, "estimate returns a promise");
+
+ return browser.test.notifyPass("navigation_storage_api.done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [{
+ "matches": ["https://example.com/*/file_sample.html"],
+ "js": ["content_script.js"],
+ }],
+ },
+
+ files: {
+ "content_script.js": `(${contentScript})()`,
+ },
+ });
+
+ await extension.startup();
+
+ // Open an explicit URL for testing Storage API in a secure context.
+ let win = window.open("file_sample.html");
+
+ await extension.awaitFinish("navigation_storage_api.done");
+
+ await extension.unload();
+ win.close();
+});
+
+add_task(async function cleanup() {
+ await SpecialPowers.popPrefEnv();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html
new file mode 100644
index 0000000000..e68caa7e55
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html
@@ -0,0 +1,108 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// The purpose of this test is making sure that the implementation enabled by
+// default for the storage.local and storage.sync APIs does work across all
+// platforms/builds/apps
+add_task(async function test_storage_smoke_test() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ for (let storageArea of ["sync", "local"]) {
+ let storage = browser.storage[storageArea];
+
+ browser.test.assertTrue(!!storage, `StorageArea ${storageArea} is present.`)
+
+ let data = await storage.get();
+ browser.test.assertEq(0, Object.keys(data).length,
+ `Storage starts out empty for ${storageArea}`);
+
+ data = await storage.get("test");
+ browser.test.assertEq(0, Object.keys(data).length,
+ `Can read non-existent keys for ${storageArea}`);
+
+ await storage.set({
+ "test1": "test-value1",
+ "test2": "test-value2",
+ "test3": "test-value3"
+ });
+
+ browser.test.assertEq(
+ "test-value1",
+ (await storage.get("test1")).test1,
+ `Can set and read back single values for ${storageArea}`);
+
+ browser.test.assertEq(
+ "test-value2",
+ (await storage.get("test2")).test2,
+ `Can set and read back single values for ${storageArea}`);
+
+ data = await storage.get();
+ browser.test.assertEq(3, Object.keys(data).length,
+ `Can set and read back all values for ${storageArea}`);
+ browser.test.assertEq("test-value1", data.test1,
+ `Can set and read back all values for ${storageArea}`);
+ browser.test.assertEq("test-value2", data.test2,
+ `Can set and read back all values for ${storageArea}`);
+ browser.test.assertEq("test-value3", data.test3,
+ `Can set and read back all values for ${storageArea}`);
+
+ data = await storage.get(["test1", "test2"]);
+ browser.test.assertEq(2, Object.keys(data).length,
+ `Can set and read back array of values for ${storageArea}`);
+ browser.test.assertEq("test-value1", data.test1,
+ `Can set and read back array of values for ${storageArea}`);
+ browser.test.assertEq("test-value2", data.test2,
+ `Can set and read back array of values for ${storageArea}`);
+
+ await storage.remove("test1");
+ data = await storage.get(["test1", "test2"]);
+ browser.test.assertEq(1, Object.keys(data).length,
+ `Data can be removed for ${storageArea}`);
+ browser.test.assertEq("test-value2", data.test2,
+ `Data can be removed for ${storageArea}`);
+
+ data = await storage.get({
+ test1: 1,
+ test2: 2,
+ });
+ browser.test.assertEq(2, Object.keys(data).length,
+ `Expected a key-value pair for every property for ${storageArea}`);
+ browser.test.assertEq(1, data.test1,
+ `Use default value if key was deleted for ${storageArea}`);
+ browser.test.assertEq("test-value2", data.test2,
+ `Use stored value if found for ${storageArea}`);
+
+ await storage.clear();
+ data = await storage.get();
+ browser.test.assertEq(0, Object.keys(data).length,
+ `Data is empty after clear for ${storageArea}`);
+ }
+
+ browser.test.sendMessage("done");
+ },
+ manifest: {
+ permissions: ["storage"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html
new file mode 100644
index 0000000000..d1bfbd824b
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test for multiple extensions trying to filterResponseData on the same request</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const TEST_URL =
+ "http://example.org/tests/toolkit/components/extensions/test/mochitest/file_streamfilter.txt";
+
+add_task(async () => {
+ const firstExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ ({ requestId }) => {
+ const filter = browser.webRequest.filterResponseData(requestId);
+ filter.ondata = event => {
+ filter.write(new TextEncoder().encode("Start "));
+ filter.write(event.data);
+ filter.disconnect();
+ };
+ },
+ {
+ urls: [
+ "http://example.org/*/file_streamfilter.txt",
+ ],
+ },
+ ["blocking"]
+ );
+ },
+ });
+
+ const secondExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ ({ requestId }) => {
+ const filter = browser.webRequest.filterResponseData(requestId);
+ filter.ondata = event => {
+ filter.write(event.data);
+ };
+ filter.onstop = event => {
+ filter.write(new TextEncoder().encode(" End"));
+ filter.close();
+ };
+ },
+ {
+ urls: [
+ "http://example.org/tests/toolkit/components/extensions/test/mochitest/file_streamfilter.txt",
+ ],
+ },
+ ["blocking"]
+ );
+ },
+ });
+
+ await firstExtension.startup();
+ await secondExtension.startup();
+
+ let iframe = document.createElement("iframe");
+ iframe.src = TEST_URL;
+ document.body.appendChild(iframe);
+ await new Promise(resolve => iframe.addEventListener("load", () => resolve(), {once: true}));
+
+ let content = await SpecialPowers.spawn(iframe, [], async () => {
+ return this.content.document.body.textContent;
+ });
+ SimpleTest.is(content, "Start Middle\n End", "Correctly intercepted page content");
+
+ await firstExtension.unload();
+ await secondExtension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html
new file mode 100644
index 0000000000..049178cad0
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html
@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test for using filterResponseData to intercept a cross-origin navigation that will involve a process switch with fission</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const TEST_HOST = "http://example.com/";
+const CROSS_ORIGIN_HOST = "http://example.org/";
+const TEST_PATH =
+ "tests/toolkit/components/extensions/test/mochitest/file_streamfilter.txt";
+
+const TEST_URL = TEST_HOST + TEST_PATH;
+const CROSS_ORIGIN_URL = CROSS_ORIGIN_HOST + TEST_PATH;
+
+add_task(async () => {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ ({ requestId }) => {
+ const filter = browser.webRequest.filterResponseData(requestId);
+ filter.onerror = () => browser.test.fail(
+ `Unexpected filterResponseData error: ${filter.error}`
+ );
+ filter.ondata = event => {
+ filter.write(event.data);
+ };
+ filter.onstop = event => {
+ filter.write(new TextEncoder().encode(" End"));
+ filter.close();
+ };
+ },
+ {
+ urls: [
+ "http://example.org/*/file_streamfilter.txt",
+ ],
+ },
+ ["blocking"]
+ );
+ },
+ });
+
+ await extension.startup();
+
+ let iframe = document.createElement("iframe");
+ iframe.src = TEST_URL;
+ document.body.appendChild(iframe);
+ await new Promise(resolve => iframe.addEventListener("load", () => resolve(), {once: true}));
+
+
+ iframe.src = CROSS_ORIGIN_URL;
+ await new Promise(resolve => iframe.addEventListener("load", () => resolve(), {once: true}));
+
+ let content = await SpecialPowers.spawn(iframe, [], async () => {
+ return this.content.document.body.textContent;
+ });
+ SimpleTest.is(content, "Middle\n End", "Correctly intercepted page content");
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html b/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html
new file mode 100644
index 0000000000..fd034f0b65
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html
@@ -0,0 +1,340 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+/* eslint-disable mozilla/balanced-listeners */
+
+add_task(async function test_webext_tab_subframe_privileges() {
+ function background() {
+ browser.runtime.onMessage.addListener(async ({msg, success, tabId, error}) => {
+ if (msg == "webext-tab-subframe-privileges") {
+ if (success) {
+ await browser.tabs.remove(tabId);
+
+ browser.test.notifyPass(msg);
+ } else {
+ browser.test.log(`Got an unexpected error: ${error}`);
+
+ let tabs = await browser.tabs.query({active: true});
+ await browser.tabs.remove(tabs[0].id);
+
+ browser.test.notifyFail(msg);
+ }
+ }
+ });
+ browser.tabs.create({url: browser.runtime.getURL("/tab.html")});
+ }
+
+ async function tabSubframeScript() {
+ browser.test.assertTrue(browser.tabs != undefined,
+ "Subframe of a privileged page has access to privileged APIs");
+ if (browser.tabs) {
+ try {
+ let tab = await browser.tabs.getCurrent();
+ browser.runtime.sendMessage({
+ msg: "webext-tab-subframe-privileges",
+ success: true,
+ tabId: tab.id,
+ });
+ } catch (e) {
+ browser.runtime.sendMessage({msg: "webext-tab-subframe-privileges", success: false, error: `${e}`});
+ }
+ } else {
+ browser.runtime.sendMessage({
+ msg: "webext-tab-subframe-privileges",
+ success: false,
+ error: `Privileged APIs missing in WebExtension tab sub-frame`,
+ });
+ }
+ }
+
+ let extensionData = {
+ background,
+ files: {
+ "tab.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <iframe src="tab-subframe.html"></iframe>
+ </body>
+ </html>`,
+ "tab-subframe.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ <script src="tab-subframe.js"><\/script>
+ </head>
+ </html>`,
+ "tab-subframe.js": tabSubframeScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ await extension.awaitFinish("webext-tab-subframe-privileges");
+ await extension.unload();
+});
+
+add_task(async function test_webext_background_subframe_privileges() {
+ function backgroundSubframeScript() {
+ browser.test.assertTrue(browser.tabs != undefined,
+ "Subframe of a background page has access to privileged APIs");
+ browser.test.notifyPass("webext-background-subframe-privileges");
+ }
+
+ let extensionData = {
+ manifest: {
+ background: {
+ page: "background.html",
+ },
+ },
+ files: {
+ "background.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <iframe src="background-subframe.html"></iframe>
+ </body>
+ </html>`,
+ "background-subframe.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ <script src="background-subframe.js"><\/script>
+ </head>
+ </html>`,
+ "background-subframe.js": backgroundSubframeScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ await extension.awaitFinish("webext-background-subframe-privileges");
+ await extension.unload();
+});
+
+add_task(async function test_webext_contentscript_iframe_subframe_privileges() {
+ function background() {
+ browser.runtime.onMessage.addListener(({name, hasTabsAPI, hasStorageAPI}) => {
+ if (name == "contentscript-iframe-loaded") {
+ browser.test.assertFalse(hasTabsAPI,
+ "Subframe of a content script privileged iframes has no access to privileged APIs");
+ browser.test.assertTrue(hasStorageAPI,
+ "Subframe of a content script privileged iframes has access to content script APIs");
+
+ browser.test.notifyPass("webext-contentscript-subframe-privileges");
+ }
+ });
+ }
+
+ function subframeScript() {
+ browser.runtime.sendMessage({
+ name: "contentscript-iframe-loaded",
+ hasTabsAPI: browser.tabs != undefined,
+ hasStorageAPI: browser.storage != undefined,
+ });
+ }
+
+ function contentScript() {
+ let iframe = document.createElement("iframe");
+ iframe.setAttribute("src", browser.runtime.getURL("/contentscript-iframe.html"));
+ document.body.appendChild(iframe);
+ }
+
+ let extensionData = {
+ background,
+ manifest: {
+ "permissions": ["storage"],
+ "content_scripts": [{
+ "matches": ["https://example.com/*"],
+ "js": ["contentscript.js"],
+ }],
+ web_accessible_resources: [
+ "contentscript-iframe.html",
+ ],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ "contentscript-iframe.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <iframe src="contentscript-iframe-subframe.html"></iframe>
+ </body>
+ </html>`,
+ "contentscript-iframe-subframe.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ <script src="contentscript-iframe-subframe.js"><\/script>
+ </head>
+ </html>`,
+ "contentscript-iframe-subframe.js": subframeScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ let win = window.open("https://example.com");
+
+ await extension.awaitFinish("webext-contentscript-subframe-privileges");
+
+ win.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_webext_background_remote_subframe_privileges() {
+ function backgroundSubframeScript() {
+ window.addEventListener("message", evt => {
+ browser.test.assertEq("http://mochi.test:8888", evt.origin, "postmessage origin ok");
+ browser.test.assertFalse(evt.data.tabs, "remote frame cannot access webextension APIs");
+ browser.test.assertEq("cookie=monster", evt.data.cookie, "Expected cookie value");
+ browser.test.notifyPass("webext-background-subframe-privileges");
+ }, {once: true});
+ browser.cookies.set({url: "http://mochi.test:8888", name: "cookie", "value": "monster"});
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: ["cookies", "*://mochi.test/*", "tabs"],
+ background: {
+ page: "background.html",
+ },
+ },
+ files: {
+ "background.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ <script src="background-subframe.js"><\/script>
+ </head>
+ <body>
+ <iframe src='${SimpleTest.getTestFileURL("file_remote_frame.html")}'></iframe>
+ </body>
+ </html>`,
+ "background-subframe.js": backgroundSubframeScript,
+ },
+ };
+ // Need remote webextensions to be able to load remote content from a background page.
+ if (!SpecialPowers.getBoolPref("extensions.webextensions.remote", true)) {
+ return;
+ }
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ await extension.awaitFinish("webext-background-subframe-privileges");
+ await extension.unload();
+});
+
+// Test a moz-extension:// iframe inside a content iframe in an extension page.
+add_task(async function test_sub_subframe_conduit_verified_env() {
+ let manifest = {
+ content_scripts: [{
+ matches: ["http://mochi.test/*/file_sample.html"],
+ all_frames: true,
+ js: ["cs.js"],
+ }],
+ background: {
+ page: "background.html",
+ },
+ web_accessible_resources: ["iframe.html"],
+ };
+
+ let files = {
+ "iframe.html": `<!DOCTYPE html><meta charset=utf-8> iframe`,
+ "cs.js"() {
+ // A compromised content sandbox shouldn't be able to trick the parent
+ // process into giving it extension privileges by sending false metadata.
+ async function faker(extensionId, envType) {
+ try {
+ let id = envType + "-xyz1234";
+ let wgc = this.content.windowGlobalChild;
+
+ let conduit = wgc.getActor("Conduits").openConduit({}, {
+ id,
+ envType,
+ extensionId,
+ query: ["CreateProxyContext"],
+ });
+
+ return await conduit.queryCreateProxyContext({
+ childId: id,
+ extensionId,
+ envType: "addon_parent",
+ url: this.content.location.href,
+ viewType: "tab",
+ });
+ } catch (e) {
+ return e.message;
+ }
+ }
+
+ let iframe = document.createElement("iframe");
+ iframe.src = browser.runtime.getURL("iframe.html");
+
+ iframe.onload = async () => {
+ for (let envType of ["content_child", "addon_child"]) {
+ let msg = await this.wrappedJSObject.SpecialPowers.spawn(
+ iframe, [browser.runtime.id, envType], faker);
+ browser.test.sendMessage(envType, msg);
+ }
+ };
+ document.body.appendChild(iframe);
+ },
+ "background.html": `<!DOCTYPE html>
+ <meta charset=utf-8>
+ <iframe src="${SimpleTest.getTestFileURL("file_sample.html")}">
+ </iframe>
+ page
+ `,
+ };
+
+ async function expectErrors(ext, log) {
+ let err = await ext.awaitMessage("content_child");
+ is(err, "Bad sender context envType: content_child");
+
+ err = await ext.awaitMessage("addon_child");
+ is(err, "Unknown sender or wrong actor for recvCreateProxyContext");
+ }
+
+ let remote = SpecialPowers.getBoolPref("extensions.webextensions.remote");
+
+ let badProcess = { message: /Bad {[\w-]+} process: web/ };
+ let badPrincipal = { message: /Bad {[\w-]+} principal: http/ };
+ consoleMonitor.start(remote ? [badPrincipal, badProcess] : [badProcess]);
+
+ let extension = ExtensionTestUtils.loadExtension({ manifest, files });
+ await extension.startup();
+
+ if (remote) {
+ info("Need OOP to spoof from a web iframe inside background page.");
+ await expectErrors(extension);
+ }
+
+ info("Try spoofing from the web process.");
+ let win = window.open("./file_sample.html");
+ await expectErrors(extension);
+ win.close();
+
+ await extension.unload();
+ await consoleMonitor.finished();
+ info("Conduit creation logged correct exception(s).");
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html
new file mode 100644
index 0000000000..ab06a965ed
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html
@@ -0,0 +1,324 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Tests tabs.captureTab and tabs.captureVisibleTab</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<script type="text/javascript">
+"use strict";
+
+async function runTest({ html, fullZoom, coords, rect, scale }) {
+ let url = `data:text/html,${encodeURIComponent(html)}#scroll`;
+
+ async function background({ coords, rect, scale, method, fullZoom }) {
+ try {
+ // Wait for the page to load
+ await new Promise(resolve => {
+ browser.webNavigation.onCompleted.addListener(
+ () => resolve(),
+ {url: [{schemes: ["data"]}]});
+ });
+
+ let [tab] = await browser.tabs.query({
+ currentWindow: true,
+ active: true,
+ });
+
+ // TODO: Bug 1665429 - on mobile we ignore zoom for now
+ if (browser.tabs.setZoom) {
+ await browser.tabs.setZoom(tab.id, fullZoom ?? 1);
+ }
+
+ let id = method === "captureVisibleTab" ? tab.windowId : tab.id;
+
+ let [jpeg, png, ...pngs] = await Promise.all([
+ browser.tabs[method](id, { format: "jpeg", quality: 95, rect, scale }),
+ browser.tabs[method](id, { format: "png", quality: 95, rect, scale }),
+ browser.tabs[method](id, { quality: 95, rect, scale }),
+ browser.tabs[method](id, { rect, scale }),
+ ]);
+
+ browser.test.assertTrue(
+ pngs.every(url => url == png),
+ "All PNGs are identical"
+ );
+
+ browser.test.assertTrue(
+ jpeg.startsWith("data:image/jpeg;base64,"),
+ "jpeg is JPEG"
+ );
+ browser.test.assertTrue(
+ png.startsWith("data:image/png;base64,"),
+ "png is PNG"
+ );
+
+ let promises = [jpeg, png].map(
+ url =>
+ new Promise(resolve => {
+ let img = new Image();
+ img.src = url;
+ img.onload = () => resolve(img);
+ })
+ );
+
+ let width = (rect?.width ?? tab.width) * (scale ?? devicePixelRatio);
+ let height = (rect?.height ?? tab.height) * (scale ?? devicePixelRatio);
+
+ [jpeg, png] = await Promise.all(promises);
+ let images = { jpeg, png };
+ for (let format of Object.keys(images)) {
+ let img = images[format];
+
+ // WGP.drawSnapshot() deals in int coordinates, and rounds down.
+ browser.test.assertTrue(
+ Math.abs(width - img.width) <= 1,
+ `${format} ok image width: ${img.width}, expected: ${width}`
+ );
+ browser.test.assertTrue(
+ Math.abs(height - img.height) <= 1,
+ `${format} ok image height ${img.height}, expected: ${height}`
+ );
+
+ let canvas = document.createElement("canvas");
+ canvas.width = img.width;
+ canvas.height = img.height;
+ canvas.mozOpaque = true;
+
+ let ctx = canvas.getContext("2d");
+ ctx.drawImage(img, 0, 0);
+
+ for (let { x, y, color } of coords) {
+ x = (x + img.width) % img.width;
+ y = (y + img.height) % img.height;
+ let imageData = ctx.getImageData(x, y, 1, 1).data;
+
+ if (format == "png") {
+ browser.test.assertEq(
+ `rgba(${color},255)`,
+ `rgba(${[...imageData]})`,
+ `${format} image color is correct at (${x}, ${y})`
+ );
+ } else {
+ // Allow for some deviation in JPEG version due to lossy compression.
+ const SLOP = 3;
+
+ browser.test.log(
+ `Testing ${format} image color at (${x}, ${y}), have rgba(${[
+ ...imageData,
+ ]}), expecting approx. rgba(${color},255)`
+ );
+
+ browser.test.assertTrue(
+ Math.abs(color[0] - imageData[0]) <= SLOP,
+ `${format} image color.red is correct at (${x}, ${y})`
+ );
+ browser.test.assertTrue(
+ Math.abs(color[1] - imageData[1]) <= SLOP,
+ `${format} image color.green is correct at (${x}, ${y})`
+ );
+ browser.test.assertTrue(
+ Math.abs(color[2] - imageData[2]) <= SLOP,
+ `${format} image color.blue is correct at (${x}, ${y})`
+ );
+ browser.test.assertEq(
+ 255,
+ imageData[3],
+ `${format} image color.alpha is correct at (${x}, ${y})`
+ );
+ }
+ }
+ }
+
+ browser.test.notifyPass("captureTab");
+ } catch (e) {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ browser.test.notifyFail("captureTab");
+ }
+ }
+
+ for (let method of ["captureTab", "captureVisibleTab"]) {
+ let options = { coords, rect, scale, method, fullZoom };
+ info(`Testing configuration: ${JSON.stringify(options)}`);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["<all_urls>", "webNavigation"],
+ },
+
+ background: `(${background})(${JSON.stringify(options)})`,
+ });
+
+ await extension.startup();
+
+ let testWindow = window.open(url);
+ await extension.awaitFinish("captureTab");
+
+ testWindow.close();
+ await extension.unload();
+ }
+}
+
+async function testEdgeToEdge({ color, fullZoom }) {
+ let neutral = [0xaa, 0xaa, 0xaa];
+
+ let html = `
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ </head>
+ <body style="background-color: rgb(${color})">
+ <!-- Fill most of the image with a neutral color to test edge-to-edge scaling. -->
+ <div style="position: absolute;
+ left: 2px;
+ right: 2px;
+ top: 2px;
+ bottom: 2px;
+ background: rgb(${neutral});"></div>
+ </body>
+ </html>
+ `;
+
+ // Check the colors of the first and last pixels of the image, to make
+ // sure we capture the entire frame, and scale it correctly.
+ let coords = [
+ { x: 0, y: 0, color },
+ { x: -1, y: -1, color },
+ { x: 300, y: 200, color: neutral },
+ ];
+
+ info(`Test edge to edge color ${color} at fullZoom=${fullZoom}`);
+ await runTest({ html, fullZoom, coords });
+}
+
+add_task(async function testCaptureEdgeToEdge() {
+ await testEdgeToEdge({ color: [0, 0, 0], fullZoom: 1 });
+ await testEdgeToEdge({ color: [0, 0, 0], fullZoom: 2 });
+ await testEdgeToEdge({ color: [0, 0, 0], fullZoom: 0.5 });
+ await testEdgeToEdge({ color: [255, 255, 255], fullZoom: 1 });
+});
+
+const tallDoc = `<!DOCTYPE html>
+ <meta charset=utf-8>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <div style="background: yellow; width: 50%; height: 500px;"></div>
+ <div id=scroll style="background: red; width: 25%; height: 5000px;"></div>
+ Opened with the #scroll fragment, scrolls the div ^ into view.
+`;
+
+// Test currently visible viewport is captured if scrolling is involved.
+add_task(async function testScrolledViewport() {
+ await runTest({
+ html: tallDoc,
+ coords: [
+ { x: 50, y: 50, color: [255, 0, 0] },
+ { x: 50, y: -50, color: [255, 0, 0] },
+ { x: -50, y: -50, color: [255, 255, 255] },
+ ],
+ });
+});
+
+// Test rect and scale options.
+add_task(async function testRectAndScale() {
+ await runTest({
+ html: tallDoc,
+ rect: { x: 50, y: 50, width: 10, height: 1000 },
+ scale: 4,
+ coords: [
+ { x: 0, y: 0, color: [255, 255, 0] },
+ { x: -1, y: 0, color: [255, 255, 0] },
+ { x: 0, y: -1, color: [255, 0, 0] },
+ { x: -1, y: -1, color: [255, 0, 0] },
+ ],
+ });
+});
+
+// Test OOP iframes are captured, for Fission compatibility.
+add_task(async function testOOPiframe() {
+ await runTest({
+ html: `<!DOCTYPE html>
+ <meta charset=utf-8>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <iframe src="http://example.net/tests/toolkit/components/extensions/test/mochitest/file_green.html"></iframe>
+ `,
+ coords: [
+ { x: 50, y: 50, color: [0, 255, 0] },
+ { x: 50, y: -50, color: [255, 255, 255] },
+ { x: -50, y: 50, color: [255, 255, 255] },
+ ],
+ });
+});
+
+add_task(async function testOOPiframeScale() {
+ let scale = 2;
+ await runTest({
+ html: `<!DOCTYPE html>
+ <meta charset=utf-8>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <style>
+ body {
+ background: yellow;
+ margin: 0;
+ }
+ </style>
+ <iframe frameborder="0" style="width: 300px; height: 300px" src="http://example.net/tests/toolkit/components/extensions/test/mochitest/file_green_blue.html"></iframe>
+ `,
+ coords: [
+ { x: 20 * scale, y: 20 * scale, color: [0, 255, 0] },
+ { x: 200 * scale, y: 20 * scale, color: [0, 0, 255] },
+ { x: 20 * scale, y: 200 * scale, color: [0, 0, 255] },
+ ],
+ scale,
+ });
+});
+
+add_task(async function testCaptureTabPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs"],
+ },
+
+ background() {
+ browser.test.assertEq(
+ undefined,
+ browser.tabs.captureTab,
+ 'Extension without "<all_urls>" permission should not have access to captureTab'
+ );
+ browser.test.notifyPass("captureTabPermissions");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("captureTabPermissions");
+ await extension.unload();
+});
+
+add_task(async function testCaptureVisibleTabPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs"],
+ },
+
+ background() {
+ browser.test.assertEq(
+ undefined,
+ browser.tabs.captureVisibleTab,
+ 'Extension without "<all_urls>" permission should not have access to captureVisibleTab'
+ );
+ browser.test.notifyPass("captureVisibleTabPermissions");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("captureVisibleTabPermissions");
+ await extension.unload();
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_create_cookieStoreId.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_create_cookieStoreId.html
new file mode 100644
index 0000000000..331faca016
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_create_cookieStoreId.html
@@ -0,0 +1,210 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <title>Test tabs.create(cookieStoreId)</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<script type="text/javascript">
+"use strict";
+
+add_task(async function no_cookies_permission() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.test.assertRejects(
+ browser.tabs.create({ cookieStoreId: "firefox-container-1" }),
+ /No permission for cookieStoreId/,
+ "cookieStoreId requires cookies permission"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function invalid_cookieStoreId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs", "cookies"],
+ },
+ async background() {
+ await browser.test.assertRejects(
+ browser.tabs.create({ cookieStoreId: "not-firefox-container-1" }),
+ /Illegal cookieStoreId/,
+ "cookieStoreId must be valid"
+ );
+
+ await browser.test.assertRejects(
+ browser.tabs.create({ cookieStoreId: "firefox-private" }),
+ /Illegal to set private cookieStoreId in a non-private window/,
+ "cookieStoreId cannot be private in a non-private window"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function perma_private_browsing_mode() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.privatebrowsing.autostart", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ permissions: ["tabs", "cookies"],
+ },
+ async background() {
+ await browser.test.assertRejects(
+ browser.tabs.create({ cookieStoreId: "firefox-container-1" }),
+ /Contextual identities are unavailable in permanent private browsing mode/,
+ "cookieStoreId cannot be a container tab ID in perma-private browsing mode"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function userContext_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", false]],
+ });
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs", "cookies"],
+ },
+ async background() {
+ await browser.test.assertRejects(
+ browser.tabs.create({ cookieStoreId: "firefox-container-1" }),
+ /Contextual identities are currently disabled/,
+ "cookieStoreId cannot be a container tab ID when contextual identities are disabled"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function valid_cookieStoreId() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ const testCases = [
+ {
+ description: "no explicit URL",
+ createProperties: {
+ cookieStoreId: "firefox-container-1",
+ },
+ expectedCookieStoreId: "firefox-container-1",
+ },
+ {
+ description: "pass explicit url",
+ createProperties: {
+ url: "about:blank",
+ cookieStoreId: "firefox-container-1",
+ },
+ expectedCookieStoreId: "firefox-container-1",
+ },{
+ description: "pass explicit not-blank url",
+ createProperties: {
+ url: "https://example.com/",
+ cookieStoreId: "firefox-container-1",
+ },
+ expectedCookieStoreId: "firefox-container-1",
+ },{
+ description: "pass extension page url",
+ createProperties: {
+ url: "blank.html",
+ cookieStoreId: "firefox-container-1",
+ },
+ expectedCookieStoreId: "firefox-container-1",
+ }
+ ];
+
+ async function background(testCases) {
+ for (let { createProperties, expectedCookieStoreId } of testCases) {
+ const { url } = createProperties;
+ const updatedPromise = new Promise(resolve => {
+ const onUpdated = (changedTabId, changed) => {
+ // Loading an extension page causes two `about:blank` messages
+ // because of the process switch
+ if (changed.url && (url == "about:blank" || changed.url != "about:blank")) {
+ browser.tabs.onUpdated.removeListener(onUpdated);
+ resolve({tabId: changedTabId, url: changed.url});
+ }
+ };
+ browser.tabs.onUpdated.addListener(onUpdated);
+ });
+
+ const tab = await browser.tabs.create(createProperties);
+ browser.test.assertEq(
+ expectedCookieStoreId,
+ tab.cookieStoreId,
+ "Expected cookieStoreId for container tab"
+ );
+
+ if (url && url !== "about:blank") {
+ // Make sure tab can load successfully
+ const updated = await updatedPromise;
+ browser.test.assertEq(tab.id, updated.tabId, `Expected value for tab.id`);
+ if (updated.url.startsWith("moz-extension")) {
+ browser.test.assertEq(browser.runtime.getURL(url), updated.url,
+ `Expected value for extension page url`);
+ } else {
+ browser.test.assertEq(url, updated.url, `Expected value for tab.url`);
+ }
+ }
+
+ await browser.tabs.remove(tab.id);
+ }
+ browser.test.sendMessage("done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs", "cookies"],
+ },
+ files: {
+ "blank.html": `<html><head><meta charset="utf-8"></head></html>`,
+ },
+ background: `(${background})(${JSON.stringify(testCases)})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_executeScript_good.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_executeScript_good.html
new file mode 100644
index 0000000000..ab3b9de5a3
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_executeScript_good.html
@@ -0,0 +1,162 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Tabs executeScript Test</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+async function testHasPermission(params) {
+ let contentSetup = params.contentSetup || (() => Promise.resolve());
+
+ async function background(contentSetup) {
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ browser.test.assertEq(msg, "script ran", "script ran");
+ browser.test.notifyPass("executeScript");
+ });
+
+ browser.test.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, "execute-script");
+
+ browser.tabs.executeScript({
+ file: "script.js",
+ });
+ });
+
+ await contentSetup();
+
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: params.manifest,
+
+ background: `(${background})(${contentSetup})`,
+
+ files: {
+ "panel.html": `<!DOCTYPE html>
+ <html>
+ <head><meta charset="utf-8"></head>
+ <body>
+ </body>
+ </html>`,
+ "script.js": function() {
+ browser.runtime.sendMessage("script ran");
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ if (params.setup) {
+ await params.setup(extension);
+ }
+
+ extension.sendMessage("execute-script");
+
+ await extension.awaitFinish("executeScript");
+
+ if (params.tearDown) {
+ await params.tearDown(extension);
+ }
+
+ await extension.unload();
+}
+
+add_task(async function testGoodPermissions() {
+ let tab = await AppTestDelegate.openNewForegroundTab(
+ window,
+ "http://mochi.test:8888/",
+ true
+ );
+
+ info("Test explicit host permission");
+ await testHasPermission({
+ manifest: { permissions: ["http://mochi.test/"] },
+ });
+
+ info("Test explicit host subdomain permission");
+ await testHasPermission({
+ manifest: { permissions: ["http://*.mochi.test/"] },
+ });
+
+ info("Test explicit <all_urls> permission");
+ await testHasPermission({
+ manifest: { permissions: ["<all_urls>"] },
+ });
+
+ info("Test activeTab permission with a browser action click");
+ await testHasPermission({
+ manifest: {
+ permissions: ["activeTab"],
+ browser_action: {},
+ },
+ contentSetup: function() {
+ browser.browserAction.onClicked.addListener(() => {
+ browser.test.log("Clicked.");
+ });
+ return Promise.resolve();
+ },
+ setup: extension => AppTestDelegate.clickBrowserAction(window, extension),
+ tearDown: extension => AppTestDelegate.closeBrowserAction(window, extension),
+ });
+
+ info("Test activeTab permission with a page action click");
+ await testHasPermission({
+ manifest: {
+ permissions: ["activeTab"],
+ page_action: {},
+ },
+ contentSetup: async () => {
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.pageAction.show(tab.id);
+ },
+ setup: extension => AppTestDelegate.clickPageAction(window, extension),
+ tearDown: extension => AppTestDelegate.closePageAction(window, extension),
+ });
+
+ info("Test activeTab permission with a browser action w/popup click");
+ await testHasPermission({
+ manifest: {
+ permissions: ["activeTab"],
+ browser_action: { default_popup: "panel.html" },
+ },
+ setup: async extension => {
+ await AppTestDelegate.clickBrowserAction(window, extension);
+ return AppTestDelegate.awaitExtensionPanel(window, extension);
+ },
+ tearDown: extension => AppTestDelegate.closeBrowserAction(window, extension),
+ });
+
+ info("Test activeTab permission with a page action w/popup click");
+ await testHasPermission({
+ manifest: {
+ permissions: ["activeTab"],
+ page_action: { default_popup: "panel.html" },
+ },
+ contentSetup: async () => {
+ let [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ await browser.pageAction.show(tab.id);
+ },
+ setup: extension => AppTestDelegate.clickPageAction(window, extension),
+ tearDown: extension => AppTestDelegate.closePageAction(window, extension),
+ });
+
+ await AppTestDelegate.removeTab(window, tab);
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html
new file mode 100644
index 0000000000..217139f12b
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html
@@ -0,0 +1,752 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Tabs permissions test</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const URL1 =
+ "https://www.example.com/tests/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html";
+const URL2 =
+ "https://example.net/tests/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html";
+
+const helperExtensionDef = {
+ manifest: {
+ permissions: ["webNavigation", "<all_urls>"],
+ },
+
+ async background() {
+ browser.test.onMessage.addListener(async message => {
+ switch (message.subject) {
+ case "createTab": {
+ const tabLoaded = new Promise(resolve => {
+ browser.webNavigation.onCompleted.addListener(function listener(
+ details
+ ) {
+ if (details.url === message.data.url) {
+ browser.webNavigation.onCompleted.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+
+ const tab = await browser.tabs.create({ url: message.data.url });
+ await tabLoaded;
+ browser.test.sendMessage("tabCreated", tab.id);
+ break;
+ }
+
+ case "changeTabURL": {
+ const tabLoaded = new Promise(resolve => {
+ browser.webNavigation.onCompleted.addListener(function listener(
+ details
+ ) {
+ if (details.url === message.data.url) {
+ browser.webNavigation.onCompleted.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+
+ await browser.tabs.update(message.data.tabId, {
+ url: message.data.url,
+ });
+ await tabLoaded;
+ browser.test.sendMessage("tabURLChanged", message.data.tabId);
+ break;
+ }
+
+ case "changeTabHashAndTitle": {
+ const tabChanged = new Promise(resolve => {
+ let hasURLChangeInfo = false,
+ hasTitleChangeInfo = false;
+ browser.tabs.onUpdated.addListener(function listener(
+ tabId,
+ changeInfo,
+ tab
+ ) {
+ if (changeInfo.url?.endsWith(message.data.urlHash)) {
+ hasURLChangeInfo = true;
+ }
+ if (changeInfo.title === message.data.title) {
+ hasTitleChangeInfo = true;
+ }
+ if (hasURLChangeInfo && hasTitleChangeInfo) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+
+ await browser.tabs.executeScript(message.data.tabId, {
+ code: `
+ document.location.hash = ${JSON.stringify(message.data.urlHash)};
+ document.title = ${JSON.stringify(message.data.title)};
+ `,
+ });
+ await tabChanged;
+ browser.test.sendMessage("tabHashAndTitleChanged");
+ break;
+ }
+
+ case "removeTab": {
+ await browser.tabs.remove(message.data.tabId);
+ browser.test.sendMessage("tabRemoved");
+ break;
+ }
+
+ default:
+ browser.test.fail(`Received unexpected message: ${message}`);
+ }
+ });
+ },
+};
+
+/*
+ * Test tabs.query function
+ * Check if the correct tabs are queried by url or title based on the granted permissions
+ */
+async function test_query(testCases, permissions) {
+ const helperExtension = ExtensionTestUtils.loadExtension(helperExtensionDef);
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions,
+ },
+
+ async background() {
+ // wait for start message
+ const [testCases, tabIdFromURL1, tabIdFromURL2] = await new Promise(
+ resolve => {
+ browser.test.onMessage.addListener(message => resolve(message));
+ }
+ );
+
+ for (const testCase of testCases) {
+ const query = testCase.query;
+ const matchingTabs = testCase.matchingTabs;
+
+ let tabQuery = await browser.tabs.query(query);
+ // ignore other tabs in the window
+ tabQuery = tabQuery.filter(tab => {
+ return tab.id === tabIdFromURL1 || tab.id === tabIdFromURL2;
+ });
+
+ browser.test.assertEq(matchingTabs, tabQuery.length, `Tabs queried`);
+ }
+ // send end message
+ browser.test.notifyPass("tabs.query");
+ },
+ });
+
+ await helperExtension.startup();
+ await extension.startup();
+
+ helperExtension.sendMessage({
+ subject: "createTab",
+ data: { url: URL1 },
+ });
+ const tabIdFromURL1 = await helperExtension.awaitMessage("tabCreated");
+
+ helperExtension.sendMessage({
+ subject: "createTab",
+ data: { url: URL2 },
+ });
+ const tabIdFromURL2 = await helperExtension.awaitMessage("tabCreated");
+
+ if (permissions.includes("activeTab")) {
+ extension.grantActiveTab(tabIdFromURL2);
+ }
+
+ extension.sendMessage([testCases, tabIdFromURL1, tabIdFromURL2]);
+ await extension.awaitFinish("tabs.query");
+
+ helperExtension.sendMessage({
+ subject: "removeTab",
+ data: { tabId: tabIdFromURL1 },
+ });
+ await helperExtension.awaitMessage("tabRemoved");
+
+ helperExtension.sendMessage({
+ subject: "removeTab",
+ data: { tabId: tabIdFromURL2 },
+ });
+ await helperExtension.awaitMessage("tabRemoved");
+
+ await extension.unload();
+ await helperExtension.unload();
+}
+
+// https://www.example.com host permission
+add_task(function query_with_host_permission_url1() {
+ return test_query(
+ [
+ {
+ query: { url: "*://www.example.com/*" },
+ matchingTabs: 1,
+ },
+ {
+ query: { url: "<all_urls>" },
+ matchingTabs: 1,
+ },
+ {
+ query: { url: ["*://www.example.com/*", "*://example.net/*"] },
+ matchingTabs: 1,
+ },
+ {
+ query: { title: "The Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: { title: "Another Title" },
+ matchingTabs: 0,
+ },
+ {
+ query: {},
+ matchingTabs: 2,
+ },
+ ],
+ ["*://www.example.com/*"]
+ );
+});
+
+// https://example.net host permission
+add_task(function query_with_host_permission_url2() {
+ return test_query(
+ [
+ {
+ query: { url: "*://www.example.com/*" },
+ matchingTabs: 0,
+ },
+ {
+ query: { url: "<all_urls>" },
+ matchingTabs: 1,
+ },
+ {
+ query: { url: ["*://www.example.com/*", "*://example.net/*"] },
+ matchingTabs: 1,
+ },
+ {
+ query: { title: "The Title" },
+ matchingTabs: 0,
+ },
+ {
+ query: { title: "Another Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: {},
+ matchingTabs: 2,
+ },
+ ],
+ ["*://example.net/*"]
+ );
+});
+
+// <all_urls> permission
+add_task(function query_with_host_permission_all_urls() {
+ return test_query(
+ [
+ {
+ query: { url: "*://www.example.com/*" },
+ matchingTabs: 1,
+ },
+ {
+ query: { url: "<all_urls>" },
+ matchingTabs: 2,
+ },
+ {
+ query: { url: ["*://www.example.com/*", "*://example.net/*"] },
+ matchingTabs: 2,
+ },
+ {
+ query: { title: "The Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: { title: "Another Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: {},
+ matchingTabs: 2,
+ },
+ ],
+ ["<all_urls>"]
+ );
+});
+
+// tabs permission
+add_task(function query_with_tabs_permission() {
+ return test_query(
+ [
+ {
+ query: { url: "*://www.example.com/*" },
+ matchingTabs: 1,
+ },
+ {
+ query: { url: "<all_urls>" },
+ matchingTabs: 2,
+ },
+ {
+ query: { url: ["*://www.example.com/*", "*://example.net/*"] },
+ matchingTabs: 2,
+ },
+ {
+ query: { title: "The Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: { title: "Another Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: {},
+ matchingTabs: 2,
+ },
+ ],
+ ["tabs"]
+ );
+});
+
+// activeTab permission
+add_task(function query_with_activeTab_permission() {
+ return test_query(
+ [
+ {
+ query: { url: "*://www.example.com/*" },
+ matchingTabs: 0,
+ },
+ {
+ query: { url: "<all_urls>" },
+ matchingTabs: 1,
+ },
+ {
+ query: { url: ["*://www.example.com/*", "*://example.net/*"] },
+ matchingTabs: 1,
+ },
+ {
+ query: { title: "The Title" },
+ matchingTabs: 0,
+ },
+ {
+ query: { title: "Another Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: {},
+ matchingTabs: 2,
+ },
+ ],
+ ["activeTab"]
+ );
+});
+// no permission
+add_task(function query_without_permission() {
+ return test_query(
+ [
+ {
+ query: { url: "*://www.example.com/*" },
+ matchingTabs: 0,
+ },
+ {
+ query: { url: "<all_urls>" },
+ matchingTabs: 0,
+ },
+ {
+ query: { url: ["*://www.example.com/*", "*://example.net/*"] },
+ matchingTabs: 0,
+ },
+ {
+ query: { title: "The Title" },
+ matchingTabs: 0,
+ },
+ {
+ query: { title: "Another Title" },
+ matchingTabs: 0,
+ },
+ {
+ query: {},
+ matchingTabs: 2,
+ },
+ ],
+ []
+ );
+});
+
+/*
+ * Test tabs.onUpdate and tabs.get function
+ * Check if the changeInfo or tab object contains the restricted properties
+ * url and title only when the right permissions are granted
+ * The tab is updated without causing navigation in order to also test activeTab permission
+ */
+async function test_restricted_properties(
+ permissions,
+ hasRestrictedProperties
+) {
+ const helperExtension = ExtensionTestUtils.loadExtension(helperExtensionDef);
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions,
+ },
+
+ async background() {
+ // wait for test start signal and data
+ const [
+ hasRestrictedProperties,
+ tabId,
+ urlHash,
+ title,
+ ] = await new Promise(resolve => {
+ browser.test.onMessage.addListener(message => {
+ resolve(message);
+ });
+ });
+
+ let hasURLChangeInfo = false,
+ hasTitleChangeInfo = false;
+ function onUpdateListener(tabId, changeInfo, tab) {
+ if (changeInfo.url?.endsWith(urlHash)) {
+ hasURLChangeInfo = true;
+ }
+ if (changeInfo.title === title) {
+ hasTitleChangeInfo = true;
+ }
+ }
+ browser.tabs.onUpdated.addListener(onUpdateListener);
+
+ // wait for test evaluation signal and data
+ await new Promise(resolve => {
+ browser.test.onMessage.addListener(message => {
+ if (message === "collectTestResults") {
+ resolve(message);
+ }
+ });
+ browser.test.sendMessage("waitingForTabPropertyChanges");
+ });
+
+ // check onUpdate changeInfo
+ browser.test.assertEq(
+ hasRestrictedProperties,
+ hasURLChangeInfo,
+ `Has changeInfo property "url"`
+ );
+ browser.test.assertEq(
+ hasRestrictedProperties,
+ hasTitleChangeInfo,
+ `Has changeInfo property "title"`
+ );
+ // check tab properties
+ const tabGet = await browser.tabs.get(tabId);
+ browser.test.assertEq(
+ hasRestrictedProperties,
+ !!tabGet.url?.endsWith(urlHash),
+ `Has tab property "url"`
+ );
+ browser.test.assertEq(
+ hasRestrictedProperties,
+ tabGet.title === title,
+ `Has tab property "title"`
+ );
+ // send end message
+ browser.test.notifyPass("tabs.restricted_properties");
+ },
+ });
+
+ const urlHash = "#ChangedURL";
+ const title = "Changed Title";
+
+ await helperExtension.startup();
+ await extension.startup();
+
+ helperExtension.sendMessage({
+ subject: "createTab",
+ data: { url: URL1 },
+ });
+ const tabId = await helperExtension.awaitMessage("tabCreated");
+
+ if (permissions.includes("activeTab")) {
+ extension.grantActiveTab(tabId);
+ }
+ // send test start signal and data
+ extension.sendMessage([hasRestrictedProperties, tabId, urlHash, title]);
+ await extension.awaitMessage("waitingForTabPropertyChanges");
+
+ helperExtension.sendMessage({
+ subject: "changeTabHashAndTitle",
+ data: {
+ tabId,
+ urlHash,
+ title,
+ },
+ });
+ await helperExtension.awaitMessage("tabHashAndTitleChanged");
+
+ // send end signal and evaluate results
+ extension.sendMessage("collectTestResults");
+ await extension.awaitFinish("tabs.restricted_properties");
+
+ helperExtension.sendMessage({
+ subject: "removeTab",
+ data: { tabId },
+ });
+ await helperExtension.awaitMessage("tabRemoved");
+
+ await extension.unload();
+ await helperExtension.unload();
+}
+
+// https://www.example.com host permission
+add_task(function has_restricted_properties_with_host_permission_url1() {
+ return test_restricted_properties(["*://www.example.com/*"], true);
+});
+// https://example.net host permission
+add_task(function has_restricted_properties_with_host_permission_url2() {
+ return test_restricted_properties(["*://example.net/*"], false);
+});
+// <all_urls> permission
+add_task(function has_restricted_properties_with_host_permission_all_urls() {
+ return test_restricted_properties(["<all_urls>"], true);
+});
+// tabs permission
+add_task(function has_restricted_properties_with_tabs_permission() {
+ return test_restricted_properties(["tabs"], true);
+});
+// activeTab permission
+add_task(function has_restricted_properties_with_activeTab_permission() {
+ return test_restricted_properties(["activeTab"], true);
+}).skip(); // TODO bug 1686080: support changeInfo.url with activeTab
+// no permission
+add_task(function has_restricted_properties_without_permission() {
+ return test_restricted_properties([], false);
+});
+
+
+/*
+ * Test tabs.onUpdate filter functionality
+ * Check if the restricted filter properties only work if the
+ * right permissions are granted
+ */
+async function test_onUpdateFilter(testCases, permissions) {
+ // Filters for onUpdated are not supported on Android.
+ if (AppConstants.platform === "android") {
+ return;
+ }
+
+ const helperExtension = ExtensionTestUtils.loadExtension(helperExtensionDef);
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions,
+ },
+
+ async background() {
+ let listenerGotCalled = false;
+ function onUpdateListener(tabId, changeInfo, tab) {
+ listenerGotCalled = true;
+ }
+
+ browser.test.onMessage.addListener(async message => {
+ switch (message.subject) {
+ case "setup": {
+ browser.tabs.onUpdated.addListener(
+ onUpdateListener,
+ message.data.filter
+ );
+ browser.test.sendMessage("done");
+ break;
+ }
+
+ case "collectTestResults": {
+ browser.test.assertEq(
+ message.data.expectEvent,
+ listenerGotCalled,
+ `Update listener called`
+ );
+ browser.tabs.onUpdated.removeListener(onUpdateListener);
+ listenerGotCalled = false;
+ browser.test.sendMessage("done");
+ break;
+ }
+
+ default:
+ browser.test.fail(`Received unexpected message: ${message}`);
+ }
+ });
+ },
+ });
+
+ await helperExtension.startup();
+ await extension.startup();
+
+ for (const testCase of testCases) {
+ helperExtension.sendMessage({
+ subject: "createTab",
+ data: { url: URL1 },
+ });
+ const tabId = await helperExtension.awaitMessage("tabCreated");
+
+ extension.sendMessage({
+ subject: "setup",
+ data: {
+ filter: testCase.filter,
+ },
+ });
+ await extension.awaitMessage("done");
+
+ helperExtension.sendMessage({
+ subject: "changeTabURL",
+ data: {
+ tabId,
+ url: URL2,
+ },
+ });
+ await helperExtension.awaitMessage("tabURLChanged");
+
+ extension.sendMessage({
+ subject: "collectTestResults",
+ data: {
+ expectEvent: testCase.expectEvent,
+ },
+ });
+ await extension.awaitMessage("done");
+
+ helperExtension.sendMessage({
+ subject: "removeTab",
+ data: { tabId },
+ });
+ await helperExtension.awaitMessage("tabRemoved");
+ }
+
+ await extension.unload();
+ await helperExtension.unload();
+}
+
+// https://mozilla.org host permission
+add_task(function onUpdateFilter_with_host_permission_url3() {
+ return test_onUpdateFilter(
+ [
+ {
+ filter: { urls: ["*://mozilla.org/*"] },
+ expectEvent: false,
+ },
+ {
+ filter: { urls: ["<all_urls>"] },
+ expectEvent: false,
+ },
+ {
+ filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] },
+ expectEvent: false,
+ },
+ {
+ filter: { properties: ["title"] },
+ expectEvent: false,
+ },
+ {
+ filter: {},
+ expectEvent: true,
+ },
+ ],
+ ["*://mozilla.org/*"]
+ );
+});
+
+// https://example.net host permission
+add_task(function onUpdateFilter_with_host_permission_url2() {
+ return test_onUpdateFilter(
+ [
+ {
+ filter: { urls: ["*://mozilla.org/*"] },
+ expectEvent: false,
+ },
+ {
+ filter: { urls: ["<all_urls>"] },
+ expectEvent: true,
+ },
+ {
+ filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] },
+ expectEvent: true,
+ },
+ {
+ filter: { properties: ["title"] },
+ expectEvent: true,
+ },
+ {
+ filter: {},
+ expectEvent: true,
+ },
+ ],
+ ["*://example.net/*"]
+ );
+});
+
+// <all_urls> permission
+add_task(function onUpdateFilter_with_host_permission_all_urls() {
+ return test_onUpdateFilter(
+ [
+ {
+ filter: { urls: ["*://mozilla.org/*"] },
+ expectEvent: false,
+ },
+ {
+ filter: { urls: ["<all_urls>"] },
+ expectEvent: true,
+ },
+ {
+ filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] },
+ expectEvent: true,
+ },
+ {
+ filter: { properties: ["title"] },
+ expectEvent: true,
+ },
+ {
+ filter: {},
+ expectEvent: true,
+ },
+ ],
+ ["<all_urls>"]
+ );
+});
+
+// tabs permission
+add_task(function onUpdateFilter_with_tabs_permission() {
+ return test_onUpdateFilter(
+ [
+ {
+ filter: { urls: ["*://mozilla.org/*"] },
+ expectEvent: false,
+ },
+ {
+ filter: { urls: ["<all_urls>"] },
+ expectEvent: true,
+ },
+ {
+ filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] },
+ expectEvent: true,
+ },
+ {
+ filter: { properties: ["title"] },
+ expectEvent: true,
+ },
+ {
+ filter: {},
+ expectEvent: true,
+ },
+ ],
+ ["tabs"]
+ );
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html
new file mode 100644
index 0000000000..80b6def0ab
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html
@@ -0,0 +1,102 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Tabs create Test</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_setup(async () => {
+ // TODO bug 1799344: remove this when the pref is true by default.
+ await SpecialPowers.pushPrefEnv({
+ "set": [
+ ["extensions.openPopupWithoutUserGesture.enabled", true],
+ ],
+ });
+});
+
+async function test_query(query) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "current-window@tests.mozilla.org",
+ }
+ },
+ permissions: ["tabs"],
+ browser_action: {
+ default_popup: "popup.html",
+ },
+ },
+
+ useAddonManager: "permanent",
+
+ background: async function() {
+ let query = await new Promise(resolve => {
+ browser.test.onMessage.addListener(message => {
+ resolve(message);
+ });
+ });
+ let tab = await browser.tabs.create({ url: "http://www.example.com", active: true });
+ browser.runtime.onMessage.addListener(message => {
+ if (message === "popup-loaded") {
+ browser.runtime.sendMessage({ tab, query });
+ }
+ });
+ browser.browserAction.openPopup();
+ },
+
+ files: {
+ "popup.html": `<!DOCTYPE html><meta charset="utf-8"><script src="popup.js"><\/script>`,
+ "popup.js"() {
+ browser.runtime.onMessage.addListener(async function({ tab, query }) {
+ let tabs = await browser.tabs.query(query);
+ browser.test.assertEq(tabs.length, 1, `Got one tab`);
+ browser.test.assertEq(tabs[0].id, tab.id, "The tab is the right one");
+
+ // Create a new tab and verify that we still see the right result
+ let newTab = await browser.tabs.create({ url: "http://www.example.com", active: true });
+ tabs = await browser.tabs.query(query);
+ browser.test.assertEq(tabs.length, 1, `Got one tab`);
+ browser.test.assertEq(tabs[0].id, newTab.id, "Got the newly-created tab");
+
+ await browser.tabs.remove(newTab.id);
+
+ // Remove the tab and verify that we see the old tab
+ tabs = await browser.tabs.query(query);
+ browser.test.assertEq(tabs.length, 1, `Got one tab`);
+ browser.test.assertEq(tabs[0].id, tab.id, "Got the tab that was active before");
+
+ // Cleanup
+ await browser.tabs.remove(tab.id);
+
+ browser.test.notifyPass("tabs.query");
+ });
+ browser.runtime.sendMessage("popup-loaded");
+ },
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(query);
+ await extension.awaitFinish("tabs.query");
+ await extension.unload();
+}
+
+add_task(function test_query_currentWindow_from_popup() {
+ return test_query({ currentWindow: true, active: true });
+});
+
+add_task(function test_query_lastActiveWindow_from_popup() {
+ return test_query({ lastFocusedWindow: true, active: true });
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html
new file mode 100644
index 0000000000..4b230c258c
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html
@@ -0,0 +1,152 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test tabs.sendMessage</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<script>
+"use strict";
+
+add_task(async function test_tabs_sendMessage_to_extension_page_frame() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [{
+ matches: ["http://mochi.test/*/file_sample.html?tabs.sendMessage"],
+ js: ["cs.js"],
+ }],
+ web_accessible_resources: ["page.html", "page.js"],
+ },
+
+ async background() {
+ let tab;
+
+ browser.runtime.onMessage.addListener(async (msg, sender) => {
+ browser.test.assertEq(msg, "page-script-ready");
+ browser.test.assertEq(sender.url, browser.runtime.getURL("page.html"));
+
+ let tabId = sender.tab.id;
+ let response = await browser.tabs.sendMessage(tabId, "tab-sendMessage");
+
+ switch (response) {
+ case "extension-tab":
+ browser.test.assertEq(tab.id, tabId, "Extension tab responded");
+ browser.test.assertEq(sender.frameId, 0, "Response from top level");
+ await browser.tabs.remove(tab.id);
+ browser.test.sendMessage("extension-tab-responded");
+ break;
+
+ case "extension-frame":
+ browser.test.assertTrue(sender.frameId > 0, "Response from iframe");
+ browser.test.sendMessage("extension-frame-responded");
+ break;
+
+ default:
+ browser.test.fail("Unexpected response: " + response);
+ }
+ });
+
+ tab = await browser.tabs.create({ url: "page.html" });
+ },
+
+ files: {
+ "cs.js"() {
+ let iframe = document.createElement("iframe");
+ iframe.src = browser.runtime.getURL("page.html");
+ document.body.append(iframe);
+ browser.test.sendMessage("content-script-done");
+ },
+
+ "page.html": `<!DOCTYPE html>
+ <meta charset=utf-8>
+ <script src=page.js><\/script>
+ Extension page`,
+
+ "page.js"() {
+ browser.runtime.onMessage.addListener(async msg => {
+ browser.test.assertEq(msg, "tab-sendMessage");
+ return window.parent === window ? "extension-tab" : "extension-frame";
+ });
+ browser.runtime.sendMessage("page-script-ready");
+ },
+ }
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("extension-tab-responded");
+
+ let win = window.open("file_sample.html?tabs.sendMessage");
+ await extension.awaitMessage("content-script-done");
+ await extension.awaitMessage("extension-frame-responded");
+ win.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_tabs_sendMessage_using_frameId() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://mochi.test/*/file_contains_iframe.html"],
+ run_at: "document_start",
+ js: ["cs_top.js"],
+ },
+ {
+ matches: ["http://example.org/*/file_contains_img.html"],
+ js: ["cs_iframe.js"],
+ all_frames: true,
+ },
+ ],
+ },
+
+ background() {
+ browser.runtime.onMessage.addListener(async (msg, sender) => {
+ let { tab, frameId } = sender;
+ browser.test.assertEq(msg, "cs_iframe_ready", "Iframe cs ready.");
+ browser.test.assertTrue(frameId > 0, "Not from the top frame.");
+
+ let response = await browser.tabs.sendMessage(tab.id, "msg");
+ browser.test.assertEq(response, "cs_top", "Top cs responded first.");
+
+ response = await browser.tabs.sendMessage(tab.id, "msg", { frameId });
+ browser.test.assertEq(response, "cs_iframe", "Iframe cs reponded.");
+
+ browser.test.sendMessage("done");
+ });
+ browser.test.sendMessage("ready");
+ },
+
+ files: {
+ "cs_top.js"() {
+ browser.test.log("Top content script loaded.")
+ browser.runtime.onMessage.addListener(async () => "cs_top");
+ },
+ "cs_iframe.js"() {
+ browser.test.log("Iframe content script loaded.")
+ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
+ browser.test.log("Iframe content script received message.")
+ setTimeout(() => sendResponse("cs_iframe"), 100);
+ return true;
+ });
+ browser.runtime.sendMessage("cs_iframe_ready");
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ let win = window.open("file_contains_iframe.html");
+ await extension.awaitMessage("done");
+ win.close();
+
+ await extension.unload();
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_test.html b/toolkit/components/extensions/test/mochitest/test_ext_test.html
new file mode 100644
index 0000000000..bf68786465
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_test.html
@@ -0,0 +1,341 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Testing test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script>
+"use strict";
+
+function loadExtensionAndInterceptTest(extensionData) {
+ let results = [];
+ let testResolve;
+ let testDone = new Promise(resolve => { testResolve = resolve; });
+ let handler = {
+ testResult(...result) {
+ result.pop();
+ results.push(result);
+ SimpleTest.info(`Received test result: ${JSON.stringify(result)}`);
+ },
+
+ testMessage(msg, ...args) {
+ results.push(["test-message", msg, ...args]);
+ SimpleTest.info(`Received message: ${msg} ${JSON.stringify(args)}`);
+ if (msg === "This is the last browser.test call") {
+ testResolve();
+ }
+ },
+ };
+ let extension = SpecialPowers.loadExtension(extensionData, handler);
+ SimpleTest.registerCleanupFunction(() => {
+ if (extension.state == "pending" || extension.state == "running") {
+ SimpleTest.ok(false, "Extension left running at test shutdown");
+ return extension.unload();
+ } else if (extension.state == "unloading") {
+ SimpleTest.ok(false, "Extension not fully unloaded at test shutdown");
+ }
+ });
+ extension.awaitResults = () => testDone.then(() => results);
+ return extension;
+}
+
+// NOTE: This test does not verify the behavior expected by calling the browser.test API methods.
+//
+// On the contrary it tests what messages ext-test.js sends to the parent process as a result of
+// processing different kind of parameters (e.g. how a dom element or a JS object with a custom
+// toString method are being serialized into strings).
+//
+// All browser.test calls results are intercepted by the test itself, see verifyTestResults for
+// the expectations of each browser.test call.
+function testScript() {
+ browser.test.notifyPass("dot notifyPass");
+ browser.test.notifyFail("dot notifyFail");
+ browser.test.log("dot log");
+ browser.test.fail("dot fail");
+ browser.test.succeed("dot succeed");
+ browser.test.assertTrue(true);
+ browser.test.assertFalse(false);
+ browser.test.assertEq("", "");
+
+ let obj = {};
+ let arr = [];
+ browser.test.assertTrue(obj, "Object truthy");
+ browser.test.assertTrue(arr, "Array truthy");
+ browser.test.assertTrue(true, "True truthy");
+ browser.test.assertTrue(false, "False truthy");
+ browser.test.assertTrue(null, "Null truthy");
+ browser.test.assertTrue(undefined, "Void truthy");
+
+ browser.test.assertFalse(obj, "Object falsey");
+ browser.test.assertFalse(arr, "Array falsey");
+ browser.test.assertFalse(true, "True falsey");
+ browser.test.assertFalse(false, "False falsey");
+ browser.test.assertFalse(null, "Null falsey");
+ browser.test.assertFalse(undefined, "Void falsey");
+
+ browser.test.assertEq(obj, obj, "Object equality");
+ browser.test.assertEq(arr, arr, "Array equality");
+ browser.test.assertEq(null, null, "Null equality");
+ browser.test.assertEq(undefined, undefined, "Void equality");
+
+ browser.test.assertEq({}, {}, "Object reference inequality");
+ browser.test.assertEq([], [], "Array reference inequality");
+ browser.test.assertEq(true, 1, "strict: true and 1 inequality");
+ browser.test.assertEq("1", 1, "strict: '1' and 1 inequality");
+ browser.test.assertEq(null, undefined, "Null and void inequality");
+
+ browser.test.assertDeepEq({a: 1, b: 1}, {b: 1, a: 1}, "Object deep eq");
+ browser.test.assertDeepEq([[2], [1]], [[2], [1]], "Array deep eq");
+ browser.test.assertDeepEq(true, 1, "strict: true and 1 deep ineq");
+ browser.test.assertDeepEq("1", 1, "strict: '1' and 1 deep ineq");
+ // Key with undefined value should be different from object without key:
+ browser.test.assertDeepEq(null, undefined, "Null and void deep ineq");
+ browser.test.assertDeepEq({c: undefined}, {c: null}, "void+null deep ineq");
+ browser.test.assertDeepEq({a: undefined, b: 1}, {b: 1}, "void/- deep ineq");
+
+ browser.test.assertDeepEq(NaN, NaN, "NaN deep eq");
+ browser.test.assertDeepEq(NaN, null, "NaN+null deep ineq");
+ browser.test.assertDeepEq(Infinity, Infinity, "Infinity deep eq");
+ browser.test.assertDeepEq(Infinity, null, "Infinity+null deep ineq");
+
+ obj = {
+ toString() {
+ return "Dynamic toString";
+ },
+ };
+ browser.test.assertEq(obj, obj, "obj with dynamic toString()");
+
+ browser.test.assertThrows(
+ () => { throw new Error("dummy"); },
+ /dummy2/,
+ "intentional failure"
+ );
+ browser.test.assertThrows(
+ () => { throw new Error("dummy2"); },
+ /dummy3/
+ );
+ browser.test.assertThrows(
+ () => {},
+ /dummy/
+ );
+
+ // The WebIDL version of assertDeepEq structurally clones before sending the
+ // params to the main thread. This check verifies that the behavior is
+ // consistent between the WebIDL and Schemas.jsm-generated API bindings.
+ browser.test.assertThrows(
+ () => browser.test.assertDeepEq(obj, obj, "obj with func"),
+ /An unexpected error occurred/,
+ "assertDeepEq obj with function throws"
+ );
+ browser.test.assertThrows(
+ () => browser.test.assertDeepEq(() => {}, () => {}, "func to assertDeepEq"),
+ /An unexpected error occurred/,
+ "assertDeepEq with function throws"
+ );
+ browser.test.assertThrows(
+ () => browser.test.assertDeepEq(/./, /./, "regexp"),
+ /Unsupported obj type: RegExp/,
+ "assertDeepEq with RegExp throws"
+ );
+
+ // Set of additional tests to only run on background page and content script
+ // (but skip on background service worker).
+ if (self === self.window) {
+ let dom = document.createElement("body");
+ browser.test.assertTrue(dom, "Element truthy");
+ browser.test.assertTrue(false, document.createElement("html"));
+ browser.test.assertFalse(dom, "Element falsey");
+ browser.test.assertFalse(true, document.createElement("head"));
+ browser.test.assertEq(dom, dom, "Element equality");
+ browser.test.assertEq(dom, document.createElement("body"), "Element inequality");
+ browser.test.assertEq(true, false, document.createElement("div"));
+ }
+
+ browser.test.sendMessage("Ran test at", location.protocol);
+ browser.test.sendMessage("This is the last browser.test call");
+}
+
+function verifyTestResults(results, shortName, expectedProtocol, useServiceWorker) {
+ let expectations = [
+ ["test-done", true, "dot notifyPass"],
+ ["test-done", false, "dot notifyFail"],
+ ["test-log", true, "dot log"],
+ ["test-result", false, "dot fail"],
+ ["test-result", true, "dot succeed"],
+ ["test-result", true, "undefined"],
+ ["test-result", true, "undefined"],
+ ["test-eq", true, "undefined", "", ""],
+
+ ["test-result", true, "Object truthy"],
+ ["test-result", true, "Array truthy"],
+ ["test-result", true, "True truthy"],
+ ["test-result", false, "False truthy"],
+ ["test-result", false, "Null truthy"],
+ ["test-result", false, "Void truthy"],
+
+ ["test-result", false, "Object falsey"],
+ ["test-result", false, "Array falsey"],
+ ["test-result", false, "True falsey"],
+ ["test-result", true, "False falsey"],
+ ["test-result", true, "Null falsey"],
+ ["test-result", true, "Void falsey"],
+
+ ["test-eq", true, "Object equality", "[object Object]", "[object Object]"],
+ ["test-eq", true, "Array equality", "", ""],
+ ["test-eq", true, "Null equality", "null", "null"],
+ ["test-eq", true, "Void equality", "undefined", "undefined"],
+
+ ["test-eq", false, "Object reference inequality", "[object Object]", "[object Object] (different)"],
+ ["test-eq", false, "Array reference inequality", "", " (different)"],
+ ["test-eq", false, "strict: true and 1 inequality", "true", "1"],
+ ["test-eq", false, "strict: '1' and 1 inequality", "1", "1 (different)"],
+ ["test-eq", false, "Null and void inequality", "null", "undefined"],
+
+ ["test-eq", true, "Object deep eq", `{"a":1,"b":1}`, `{"b":1,"a":1}`],
+ ["test-eq", true, "Array deep eq", "[[2],[1]]", "[[2],[1]]"],
+ ["test-eq", false, "strict: true and 1 deep ineq", "true", "1"],
+ ["test-eq", false, "strict: '1' and 1 deep ineq", `"1"`, "1"],
+ ["test-eq", false, "Null and void deep ineq", "null", "undefined"],
+ ["test-eq", false, "void+null deep ineq", `{"c":"undefined"}`, `{"c":null}`],
+ ["test-eq", false, "void/- deep ineq", `{"a":"undefined","b":1}`, `{"b":1}`],
+
+ ["test-eq", true, "NaN deep eq", `NaN`, `NaN`],
+ ["test-eq", false, "NaN+null deep ineq", `NaN`, `null`],
+ ["test-eq", true, "Infinity deep eq", `Infinity`, `Infinity`],
+ ["test-eq", false, "Infinity+null deep ineq", `Infinity`, `null`],
+
+ [
+ "test-eq",
+ true,
+ "obj with dynamic toString()",
+ // - Privileged JS API Bindings: the ext-test.js module will get a XrayWrapper and so when
+ // the object is being stringified the custom `toString()` method will not be called and
+ // "[object Object]" is the value we expect.
+ // - WebIDL API Bindngs: the parameter is being serialized into a string on the worker thread,
+ // the object is stringified using the worker principal and so there is no XrayWrapper
+ // involved and the value expected is the value returned by the custom toString method the.
+ // object does provide.
+ useServiceWorker ? "Dynamic toString" : "[object Object]",
+ useServiceWorker ? "Dynamic toString" : "[object Object]",
+ ],
+
+ [
+ "test-result", false,
+ "Function threw, expecting error to match '/dummy2/', got \'Error: dummy\': intentional failure"
+ ],
+ [
+ "test-result", false,
+ "Function threw, expecting error to match '/dummy3/', got \'Error: dummy2\'"
+ ],
+ [
+ "test-result", false,
+ "Function did not throw, expected error '/dummy/'"
+ ],
+ [
+ "test-result", true,
+ "Function threw, expecting error to match '/An unexpected error occurred/', got 'Error: An unexpected error occurred': assertDeepEq obj with function throws",
+ ],
+ [
+ "test-result", true,
+ "Function threw, expecting error to match '/An unexpected error occurred/', got 'Error: An unexpected error occurred': assertDeepEq with function throws",
+ ],
+ [
+ "test-result", true,
+ "Function threw, expecting error to match '/Unsupported obj type: RegExp/', got 'Error: Unsupported obj type: RegExp': assertDeepEq with RegExp throws",
+ ],
+ ];
+
+ if (!useServiceWorker) {
+ expectations.push(...[
+ ["test-result", true, "Element truthy"],
+ ["test-result", false, "[object HTMLHtmlElement]"],
+ ["test-result", false, "Element falsey"],
+ ["test-result", false, "[object HTMLHeadElement]"],
+ ["test-eq", true, "Element equality", "[object HTMLBodyElement]", "[object HTMLBodyElement]"],
+ ["test-eq", false, "Element inequality", "[object HTMLBodyElement]", "[object HTMLBodyElement] (different)"],
+ ["test-eq", false, "[object HTMLDivElement]", "true", "false"],
+ ]);
+ }
+
+ expectations.push(...[
+ ["test-message", "Ran test at", expectedProtocol],
+ ["test-message", "This is the last browser.test call"],
+ ]);
+
+ expectations.forEach((expectation, i) => {
+ let msg = expectation.slice(2).join(" - ");
+ isDeeply(results[i], expectation, `${shortName} (${msg})`);
+ });
+ is(results[expectations.length], undefined, "No more results");
+}
+
+add_task(async function test_test_in_background() {
+ let extensionData = {
+ background: `(${testScript})()`,
+ // This test case should never run the background script in a worker,
+ // even if this test file is running when "extensions.backgroundServiceWorker.forceInTest"
+ // pref is true
+ useServiceWorker: false,
+ };
+
+ let extension = loadExtensionAndInterceptTest(extensionData);
+ await extension.startup();
+ let results = await extension.awaitResults();
+ verifyTestResults(results, "background page", "moz-extension:", false);
+ await extension.unload();
+});
+
+add_task(async function test_test_in_background_service_worker() {
+ if (!ExtensionTestUtils.isInBackgroundServiceWorkerTests()) {
+ is(
+ ExtensionTestUtils.getBackgroundServiceWorkerEnabled(),
+ false,
+ "This test should only be skipped with background service worker disabled"
+ )
+ info("Test intentionally skipped on 'extensions.backgroundServiceWorker.enabled=false'");
+ return;
+ }
+
+ let extensionData = {
+ background: `(${testScript})()`,
+ // This test case should always run the background script in a worker,
+ // or be skipped if the background service worker is disabled by prefs.
+ useServiceWorker: true,
+ };
+
+ let extension = loadExtensionAndInterceptTest(extensionData);
+ await extension.startup();
+ let results = await extension.awaitResults();
+ verifyTestResults(results, "background service worker", "moz-extension:", true);
+ await extension.unload();
+});
+
+add_task(async function test_test_in_content_script() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ matches: ["http://mochi.test/*/file_sample.html"],
+ js: ["contentscript.js"],
+ }],
+ },
+ files: {
+ "contentscript.js": `(${testScript})()`,
+ },
+ };
+
+ let extension = loadExtensionAndInterceptTest(extensionData);
+ await extension.startup();
+ let win = window.open("file_sample.html");
+ let results = await extension.awaitResults();
+ win.close();
+ verifyTestResults(results, "content script", "http:", false);
+ await extension.unload();
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html b/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html
new file mode 100644
index 0000000000..db0f512ac3
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html
@@ -0,0 +1,138 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <script type="text/javascript" src="head_unlimitedStorage.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+
+"use strict";
+
+async function test_background_storagePersist(EXTENSION_ID) {
+ await SpecialPowers.pushPrefEnv({
+ "set": [
+ ["dom.storageManager.enabled", true],
+ ["dom.storageManager.prompt.testing", false],
+ ["dom.storageManager.prompt.testing.allow", false],
+ ],
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+
+ manifest: {
+ permissions: ["storage", "unlimitedStorage"],
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+
+ background: async function() {
+ const PROMISE_RACE_TIMEOUT = 8000;
+
+ browser.test.sendMessage("extension-uuid", window.location.host);
+
+ await browser.storage.local.set({testkey: "testvalue"});
+ await browser.test.sendMessage("storage-local-called");
+
+ const requestStoragePersist = async () => {
+ const persistAllowed = await navigator.storage.persist();
+ if (!persistAllowed) {
+ throw new Error("navigator.storage.persist() has been denied");
+ }
+ };
+
+ await Promise.race([
+ requestStoragePersist(),
+ new Promise((resolve, reject) => {
+ setTimeout(() => {
+ reject(new Error("Timeout opening persistent db from background page"));
+ }, PROMISE_RACE_TIMEOUT);
+ }),
+ ]).then(
+ () => {
+ browser.test.notifyPass("indexeddb-storagePersistent-unlimitedStorage-done");
+ },
+ (error) => {
+ browser.test.fail(`error while testing persistent IndexedDB storage: ${error}`);
+ browser.test.notifyFail("indexeddb-storagePersistent-unlimitedStorage-done");
+ }
+ );
+ },
+ });
+
+ await extension.startup();
+
+ const uuid = await extension.awaitMessage("extension-uuid");
+
+ await extension.awaitMessage("storage-local-called");
+
+ let chromeScript = SpecialPowers.loadChromeScript(function test_country_data() {
+ /* eslint-env mozilla/chrome-script */
+ const {addMessageListener, sendAsyncMessage} = this;
+
+ addMessageListener("getPersistedStatus", (uuid) => {
+ const {
+ ExtensionStorageIDB,
+ } = ChromeUtils.import("resource://gre/modules/ExtensionStorageIDB.jsm");
+
+ const {WebExtensionPolicy} = Cu.getGlobalForObject(ExtensionStorageIDB);
+ const policy = WebExtensionPolicy.getByHostname(uuid);
+ const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(policy.extension);
+ const request = Services.qms.persisted(storagePrincipal);
+ request.callback = () => {
+ // request.result will be undeinfed if the request failed (request.resultCode !== Cr.NS_OK).
+ sendAsyncMessage("gotPersistedStatus", request.result);
+ };
+ });
+ });
+
+ const persistedPromise = chromeScript.promiseOneMessage("gotPersistedStatus");
+ chromeScript.sendAsyncMessage("getPersistedStatus", uuid);
+ is(await persistedPromise, true, "Got the expected persist status for the storagePrincipal");
+
+ await extension.awaitFinish("indexeddb-storagePersistent-unlimitedStorage-done");
+ await extension.unload();
+
+ checkSitePermissions(uuid, Services.perms.UNKNOWN_ACTION, "has been cleared");
+}
+
+add_task(async function test_unlimitedStorage() {
+ const EXTENSION_ID = "test-storagePersist@mozilla";
+ await SpecialPowers.pushPrefEnv({
+ "set": [
+ ["extensions.webextensions.ExtensionStorageIDB.enabled", true],
+ ],
+ });
+
+ // Verify persist mode enabled when the storage.local IDB database is opened from
+ // the main process (from parent/ext-storage.js).
+ info("Test unlimitedStorage on an extension migrating to the IndexedDB storage.local backend)");
+ await test_background_storagePersist(EXTENSION_ID);
+
+ await SpecialPowers.pushPrefEnv({
+ "set": [
+ [`extensions.webextensions.ExtensionStorageIDB.migrated.` + EXTENSION_ID, true],
+ ],
+ });
+
+ // Verify persist mode enabled when the storage.local IDB database is opened from
+ // the child process (from child/ext-storage.js).
+ info("Test unlimitedStorage on an extension migrated to the IndexedDB storage.local backend");
+ await test_background_storagePersist(EXTENSION_ID);
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html
new file mode 100644
index 0000000000..d1c41d2030
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html
@@ -0,0 +1,170 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test the web_accessible_resources incognito</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+let image = atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" +
+ "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=");
+const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)).buffer;
+
+async function testImageLoading(src, expectedAction) {
+ let imageLoadingPromise = new Promise((resolve, reject) => {
+ let cleanupListeners;
+ let testImage = new window.Image();
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ testImage.wrappedJSObject.setAttribute("src", src);
+
+ let loadListener = () => {
+ cleanupListeners();
+ resolve(expectedAction === "loaded");
+ };
+
+ let errorListener = (event) => {
+ cleanupListeners();
+ resolve(expectedAction === "blocked");
+ browser.test.log(`+++ image loading ${event.error}`);
+ };
+
+ cleanupListeners = () => {
+ testImage.removeEventListener("load", loadListener);
+ testImage.removeEventListener("error", errorListener);
+ };
+
+ testImage.addEventListener("load", loadListener);
+ testImage.addEventListener("error", errorListener);
+ });
+
+ let success = await imageLoadingPromise;
+ browser.runtime.sendMessage({name: "image-loading", expectedAction, success});
+}
+
+function testScript() {
+ window.postMessage("test-script-loaded", "*");
+}
+
+add_task(async function test_web_accessible_resources_incognito() {
+ // This extension will not have access to private browsing so its
+ // accessible resources should not be able to load in them.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ "web_accessible_resources": [
+ "image.png",
+ "test_script.js",
+ "accessible.html",
+ ],
+ },
+ background() {
+ browser.test.sendMessage("url", browser.runtime.getURL(""));
+ },
+ files: {
+ "image.png": IMAGE_ARRAYBUFFER,
+ "test_script.js": testScript,
+ "accessible.html": `<html><head>
+ <meta charset="utf-8">
+ </head></html>`,
+ },
+ });
+
+ await extension.startup();
+ let baseUrl = await extension.awaitMessage("url");
+
+ async function content() {
+ let baseUrl = await browser.runtime.sendMessage({name: "get-url"});
+ testImageLoading(`${baseUrl}image.png`, "loaded");
+
+ let testScriptElement = document.createElement("script");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ testScriptElement.wrappedJSObject.setAttribute("src", `${baseUrl}test_script.js`);
+ document.head.appendChild(testScriptElement);
+
+ let iframe = document.createElement("iframe");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ iframe.wrappedJSObject.setAttribute("src", `${baseUrl}accessible.html`);
+ document.body.appendChild(iframe);
+
+ // eslint-disable-next-line mozilla/balanced-listeners
+ window.addEventListener("message", event => {
+ browser.runtime.sendMessage({"name": event.data});
+ });
+ }
+
+ let pb_extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ permissions: ["tabs"],
+ content_scripts: [{
+ "matches": ["*://example.com/*/file_sample.html"],
+ "run_at": "document_end",
+ "js": ["content_script_helper.js", "content_script.js"],
+ }],
+ },
+ files: {
+ "content_script_helper.js": `${testImageLoading}`,
+ "content_script.js": content,
+ },
+ background() {
+ let url = "http://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html";
+ let baseUrl;
+ let window;
+
+ browser.runtime.onMessage.addListener(async msg => {
+ switch (msg.name) {
+ case "image-loading":
+ browser.test.assertFalse(msg.success, `Image was ${msg.expectedAction}`);
+ browser.test.sendMessage(`image-${msg.expectedAction}`);
+ break;
+ case "get-url":
+ return baseUrl;
+ default:
+ browser.test.fail(`unexepected message ${msg.name}`);
+ }
+ });
+
+ browser.test.onMessage.addListener(async (msg, data) => {
+ if (msg == "start") {
+ baseUrl = data;
+ window = await browser.windows.create({url, incognito: true});
+ }
+ if (msg == "close") {
+ browser.windows.remove(window.id);
+ }
+ });
+ },
+ });
+ await pb_extension.startup();
+
+ consoleMonitor.start([
+ {message: /may not load or link to.*image.png/},
+ {message: /may not load or link to.*test_script.js/},
+ {message: /\<script\> source URI is not allowed in this document/},
+ {message: /may not load or link to.*accessible.html/},
+ ]);
+
+ pb_extension.sendMessage("start", baseUrl);
+
+ await pb_extension.awaitMessage("image-loaded");
+
+ pb_extension.sendMessage("close");
+
+ await extension.unload();
+ await pb_extension.unload();
+
+ await consoleMonitor.finished();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html
new file mode 100644
index 0000000000..c13e40e265
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html
@@ -0,0 +1,567 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test the web_accessible_resources manifest directive</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// add_setup not available in mochitest
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({set: [["extensions.manifestV3.enabled", true]]});
+})
+
+let image = atob(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" +
+ "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="
+);
+const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0))
+ .buffer;
+
+const ANDROID = navigator.userAgent.includes("Android");
+
+async function testImageLoading(src, expectedAction) {
+ let imageLoadingPromise = new Promise((resolve, reject) => {
+ let cleanupListeners;
+ let testImage = document.createElement("img");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ testImage.wrappedJSObject.setAttribute("src", src);
+
+ let loadListener = () => {
+ cleanupListeners();
+ resolve(expectedAction === "loaded");
+ };
+
+ let errorListener = () => {
+ cleanupListeners();
+ resolve(expectedAction === "blocked");
+ };
+
+ cleanupListeners = () => {
+ testImage.removeEventListener("load", loadListener);
+ testImage.removeEventListener("error", errorListener);
+ };
+
+ testImage.addEventListener("load", loadListener);
+ testImage.addEventListener("error", errorListener);
+
+ document.body.appendChild(testImage);
+ });
+
+ let success = await imageLoadingPromise;
+ browser.runtime.sendMessage({
+ name: "image-loading",
+ expectedAction,
+ success,
+ });
+}
+
+async function _test_web_accessible_resources({
+ manifest,
+ expectShouldLoadByDefault = true,
+ usePagePrincipal = false,
+}) {
+ function background(shouldLoad, usePagePrincipal) {
+ let gotURL;
+ let tabId;
+ let expectBrowserAPI;
+
+ function loadFrame(url, sandbox = null, srcdoc = false) {
+ return new Promise(resolve => {
+ browser.tabs.sendMessage(
+ tabId,
+ ["load-iframe", url, sandbox, srcdoc, usePagePrincipal],
+ reply => {
+ resolve(reply);
+ }
+ );
+ });
+ }
+
+ // shouldLoad will be true unless we expect all attempts to fail.
+ let urls = [
+ // { url, shouldLoad, sandbox, srcdoc }
+ {
+ url: browser.runtime.getURL("accessible.html"),
+ shouldLoad,
+ },
+ {
+ url: browser.runtime.getURL("accessible.html") + "?foo=bar",
+ shouldLoad,
+ },
+ {
+ url: browser.runtime.getURL("accessible.html") + "#!foo=bar",
+ shouldLoad,
+ },
+ {
+ url: browser.runtime.getURL("accessible.html"),
+ shouldLoad,
+ sandbox: "allow-scripts",
+ },
+ {
+ url: browser.runtime.getURL("accessible.html"),
+ shouldLoad,
+ sandbox: "allow-same-origin allow-scripts",
+ },
+ {
+ url: browser.runtime.getURL("accessible.html"),
+ shouldLoad,
+ sandbox: "allow-scripts",
+ srcdoc: true,
+ },
+ {
+ url: browser.runtime.getURL("inaccessible.html"),
+ shouldLoad: false,
+ },
+ {
+ url: browser.runtime.getURL("inaccessible.html"),
+ shouldLoad: false,
+ sandbox: "allow-same-origin allow-scripts",
+ },
+ {
+ url: browser.runtime.getURL("inaccessible.html"),
+ shouldLoad: false,
+ sandbox: "allow-same-origin allow-scripts",
+ srcdoc: true,
+ },
+ {
+ url: browser.runtime.getURL("wild1.html"),
+ shouldLoad,
+ },
+ {
+ url: browser.runtime.getURL("wild2.htm"),
+ shouldLoad: false,
+ },
+ ];
+
+ async function runTests() {
+ for (let { url, shouldLoad, sandbox, srcdoc } of urls) {
+ // Sandboxed pages with an opaque origin do not get browser api.
+ expectBrowserAPI = !sandbox || sandbox.includes("allow-same-origin");
+ let success = await loadFrame(url, sandbox, srcdoc);
+
+ browser.test.assertEq(shouldLoad, success, "Load was successful");
+ if (shouldLoad && !srcdoc) {
+ browser.test.assertEq(url, gotURL, "Got expected url");
+ } else {
+ browser.test.assertEq(undefined, gotURL, "Got no url");
+ }
+ gotURL = undefined;
+ }
+
+ browser.test.notifyPass("web-accessible-resources");
+ }
+
+ browser.runtime.onMessage.addListener(
+ ([msg, url, hasBrowserAPI], sender) => {
+ if (msg == "content-script-ready") {
+ tabId = sender.tab.id;
+ runTests();
+ } else if (msg == "page-script") {
+ browser.test.assertEq(
+ undefined,
+ gotURL,
+ "Should have gotten only one message"
+ );
+ browser.test.assertEq("string", typeof url, "URL should be a string");
+ browser.test.assertEq(
+ expectBrowserAPI,
+ hasBrowserAPI,
+ "has access to browser api"
+ );
+ gotURL = url;
+ }
+ }
+ );
+
+ browser.test.sendMessage("ready");
+ }
+
+ function contentScript() {
+ window.addEventListener("message", event => {
+ // bounce the postmessage to the background script
+ if (event.data[0] == "page-script") {
+ browser.runtime.sendMessage(event.data);
+ }
+ });
+
+ browser.runtime.onMessage.addListener(
+ ([msg, url, sandboxed, srcdoc, usePagePrincipal], sender, respond) => {
+ if (msg == "load-iframe") {
+ // construct the frame using srcdoc if requested.
+ if (srcdoc) {
+ sandboxed = sandboxed !== null ? `sandbox="${sandboxed}"` : "";
+ let frameSrc = `<iframe ${sandboxed} src="${url}" onload="parent.postMessage(true, '*')" onerror="parent.postMessage(false, '*')">`;
+ let frame = document.createElement("iframe");
+ frame.setAttribute("srcdoc", frameSrc);
+ window.addEventListener("message", function listener(event) {
+ if (event.source === frame.contentWindow) {
+ window.removeEventListener("message", listener);
+ respond(event.data);
+ }
+ });
+ document.body.appendChild(frame);
+ return true;
+ }
+
+ let iframe = document.createElement("iframe");
+ if (sandboxed !== null) {
+ iframe.setAttribute("sandbox", sandboxed);
+ }
+
+ if (usePagePrincipal) {
+ // Test using the page principal
+ iframe.wrappedJSObject.src = url;
+ } else {
+ // Test using the expanded principal
+ iframe.src = url;
+ }
+ iframe.addEventListener("load", () => {
+ respond(true);
+ });
+ iframe.addEventListener("error", () => {
+ respond(false);
+ });
+ document.body.appendChild(iframe);
+ return true;
+ }
+ }
+ );
+ browser.runtime.sendMessage(["content-script-ready"]);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["https://example.com/"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+ ...manifest,
+ },
+
+ background: `(${background})(${expectShouldLoadByDefault}, ${usePagePrincipal})`,
+
+ files: {
+ "content_script.js": contentScript,
+
+ "accessible.html": `<!DOCTYPE html><html><head>
+ <meta charset="utf-8">
+ <script src="pagescript.js"><\/script>
+ </head></html>`,
+
+ "inaccessible.html": `<!DOCTYPE html><html><head>
+ <meta charset="utf-8">
+ <script src="pagescript.js"><\/script>
+ </head></html>`,
+
+ "wild1.html": `<!DOCTYPE html><html><head>
+ <meta charset="utf-8">
+ <script src="pagescript.js"><\/script>
+ </head></html>`,
+
+ "wild2.htm": `<!DOCTYPE html><html><head>
+ <meta charset="utf-8">
+ <script src="pagescript.js"><\/script>
+ </head></html>`,
+
+ "pagescript.js":
+ // We postmessage so we can determine when browser is not available
+ 'window.parent.postMessage(["page-script", location.href, typeof browser !== "undefined"], "*");',
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("ready");
+
+ let win = window.open("https://example.com/");
+
+ await extension.awaitFinish("web-accessible-resources");
+
+ win.close();
+
+ await extension.unload();
+};
+
+add_task(async function test_web_accessible_resources_v2() {
+ await SpecialPowers.pushPrefEnv({set: [["extensions.content_web_accessible.enabled", true]]});
+ consoleMonitor.start([
+ {message: /Content at https:\/\/example.com\/ may not load or link to.*inaccessible.html/},
+ ]);
+ await _test_web_accessible_resources({
+ manifest: {
+ manifest_version: 2,
+ web_accessible_resources: ["/accessible.html", "wild*.html"],
+ }
+ });
+ await consoleMonitor.finished();
+ await SpecialPowers.popPrefEnv();
+});
+
+// Same test as above, but using only the content principal
+add_task(async function test_web_accessible_resources_v2_content() {
+ await SpecialPowers.pushPrefEnv({set: [["extensions.content_web_accessible.enabled", true]]});
+ consoleMonitor.start([
+ {message: /Content at https:\/\/example.com\/ may not load or link to.*inaccessible.html/},
+ ]);
+ await _test_web_accessible_resources({
+ manifest: {
+ manifest_version: 2,
+ web_accessible_resources: ["/accessible.html", "wild*.html"],
+ },
+ usePagePrincipal: true,
+ });
+ await consoleMonitor.finished();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_web_accessible_resources_v3() {
+ // MV3 always requires this, pref off to ensure it works.
+ await SpecialPowers.pushPrefEnv({set: [["extensions.content_web_accessible.enabled", false]]});
+ consoleMonitor.start([
+ {message: /Content at https:\/\/example.com\/ may not load or link to.*inaccessible.html/},
+ ]);
+ await _test_web_accessible_resources({
+ manifest: {
+ manifest_version: 3,
+ web_accessible_resources: [
+ {
+ resources: ["/accessible.html", "wild*.html"],
+ matches: ["*://example.com/*"]
+ },
+ ],
+ host_permissions: ["*://example.com/*"],
+ granted_host_permissions: true,
+ }
+ });
+ await consoleMonitor.finished();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_web_accessible_resources_v3_by_id() {
+ consoleMonitor.start([
+ {message: /Content at https:\/\/example.com\/ may not load or link to.*accessible.html/},
+ {message: /Content at https:\/\/example.com\/ may not load or link to.*inaccessible.html/},
+ ]);
+ await _test_web_accessible_resources({
+ manifest: {
+ manifest_version: 3,
+ browser_specific_settings: {
+ gecko: {
+ id: "extension_wac@mochitest",
+ },
+ },
+ web_accessible_resources: [
+ {
+ resources: ["/accessible.html", "wild*.html"],
+ extension_ids: ["extension_wac@mochitest"]
+ },
+ ],
+ host_permissions: ["*://example.com/*"],
+ // Work-around for bug 1766752 to allow content_scripts to run:
+ granted_host_permissions: true,
+ },
+ expectShouldLoadByDefault: false,
+ });
+ await consoleMonitor.finished();
+});
+
+add_task(async function test_web_accessible_resources_mixed_content() {
+ function background() {
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg.name === "image-loading") {
+ browser.test.assertTrue(msg.success, `Image was ${msg.expectedAction}`);
+ browser.test.sendMessage(`image-${msg.expectedAction}`);
+ } else {
+ browser.test.sendMessage(msg);
+ if (msg === "accessible-script-loaded") {
+ browser.test.notifyPass("mixed-test");
+ }
+ }
+ });
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ async function content() {
+ await testImageLoading(
+ "http://example.com/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png",
+ "blocked"
+ );
+ await testImageLoading(browser.runtime.getURL("image.png"), "loaded");
+
+ let testScriptElement = document.createElement("script");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ testScriptElement.wrappedJSObject.setAttribute(
+ "src",
+ browser.runtime.getURL("test_script.js")
+ );
+ document.head.appendChild(testScriptElement);
+
+ window.addEventListener("message", event => {
+ browser.runtime.sendMessage(event.data);
+ });
+ }
+
+ function testScript() {
+ window.postMessage("accessible-script-loaded", "*");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["https://example.com/*/file_mixed.html"],
+ run_at: "document_end",
+ js: ["content_script_helper.js", "content_script.js"],
+ },
+ ],
+ web_accessible_resources: ["image.png", "test_script.js"],
+ },
+ background,
+ files: {
+ "content_script_helper.js": `${testImageLoading}`,
+ "content_script.js": content,
+ "test_script.js": testScript,
+ "image.png": IMAGE_ARRAYBUFFER,
+ },
+ });
+
+ await SpecialPowers.pushPrefEnv({set: [
+ ["security.mixed_content.upgrade_display_content", false],
+ ["security.mixed_content.block_display_content", true],
+ ]});
+
+ await Promise.all([
+ extension.startup(),
+ extension.awaitMessage("background-ready"),
+ ]);
+
+ let win = window.open(
+ "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_mixed.html"
+ );
+
+ await Promise.all([
+ extension.awaitMessage("image-blocked"),
+ extension.awaitMessage("image-loaded"),
+ extension.awaitMessage("accessible-script-loaded"),
+ ]);
+ await extension.awaitFinish("mixed-test");
+ win.close();
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+// test that MV2 extensions continue to open other MV2 extension pages
+// when they are not listed in web_accessible_resources. This test also
+// covers mobile/android tab creation.
+add_task(async function test_web_accessible_resources_extensions_MV2() {
+ function background() {
+ let newtab;
+ let win;
+ let expectUrl;
+ browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
+ if (!expectUrl || tab.url != expectUrl || changeInfo.status !== "complete") {
+ return;
+ }
+ expectUrl = undefined;
+ browser.test.log(`onUpdated ${JSON.stringify(changeInfo)} ${tab.url}`);
+ browser.test.sendMessage("onUpdated", tab.url);
+ });
+ browser.test.onMessage.addListener(async (msg, url) => {
+ browser.test.log(`onMessage ${msg} ${url}`);
+ expectUrl = url;
+ if (msg == "create") {
+ newtab = await browser.tabs.create({ url });
+ browser.test.assertTrue(
+ newtab.id !== browser.tabs.TAB_ID_NONE,
+ "New tab was created."
+ );
+ } else if (msg == "update") {
+ await browser.tabs.update(newtab.id, { url });
+ } else if (msg == "remove") {
+ await browser.tabs.remove(newtab.id);
+ newtab = null;
+ browser.test.sendMessage("completed");
+ } else if (msg == "open-window") {
+ win = await browser.windows.create({ url });
+ } else if (msg == "close-window") {
+ await browser.windows.remove(win.id);
+ browser.test.sendMessage("completed");
+ win = null;
+ }
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "this-mv2@mochitest" } },
+ },
+ background,
+ files: {
+ "page.html": `<!DOCTYPE html><html><head>
+ <meta charset="utf-8">
+ </head></html>`,
+ },
+ });
+
+ async function testTabsAction(ext, action, url) {
+ ext.sendMessage(action, url);
+ is(await ext.awaitMessage("onUpdated"), url, "extension url was loaded");
+ }
+
+ await extension.startup();
+ let extensionUrl = `moz-extension://${extension.uuid}/page.html`;
+
+ // Test opening its own pages
+ await testTabsAction(extension, "create", `${extensionUrl}?q=1`);
+ await testTabsAction(extension, "update", `${extensionUrl}?q=2`);
+ extension.sendMessage("remove");
+ await extension.awaitMessage("completed");
+ if (!ANDROID) {
+ await testTabsAction(extension, "open-window", `${extensionUrl}?q=3`);
+ extension.sendMessage("close-window");
+ await extension.awaitMessage("completed");
+ }
+
+ // Extension used to open the homepage in a new window.
+ let other = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs", "<all_urls>"],
+ },
+ background,
+ });
+ await other.startup();
+
+ // Test opening another extensions pages
+ await testTabsAction(other, "create", `${extensionUrl}?q=4`);
+ await testTabsAction(other, "update", `${extensionUrl}?q=5`);
+ other.sendMessage("remove");
+ await other.awaitMessage("completed");
+ if (!ANDROID) {
+ await testTabsAction(other, "open-window", `${extensionUrl}?q=6`);
+ other.sendMessage("close-window");
+ await other.awaitMessage("completed");
+ }
+
+ await extension.unload();
+ await other.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html
new file mode 100644
index 0000000000..12c90f8350
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html
@@ -0,0 +1,610 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+if (AppConstants.platform === "android") {
+ SimpleTest.requestLongerTimeout(3);
+}
+
+/* globals sendMouseEvent */
+
+function backgroundScript() {
+ const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest";
+ const URL = BASE + "/file_WebNavigation_page1.html";
+
+ const EVENTS = [
+ "onTabReplaced",
+ "onBeforeNavigate",
+ "onCommitted",
+ "onDOMContentLoaded",
+ "onCompleted",
+ "onErrorOccurred",
+ "onReferenceFragmentUpdated",
+ "onHistoryStateUpdated",
+ ];
+
+ let expectedTabId = -1;
+
+ function gotEvent(event, details) {
+ if (!details.url.startsWith(BASE)) {
+ return;
+ }
+ browser.test.log(`Got ${event} ${details.url} ${details.frameId} ${details.parentFrameId}`);
+
+ if (expectedTabId == -1) {
+ browser.test.assertTrue(details.tabId !== undefined, "tab ID defined");
+ expectedTabId = details.tabId;
+ }
+
+ browser.test.assertEq(details.tabId, expectedTabId, "correct tab");
+
+ browser.test.sendMessage("received", {url: details.url, event});
+
+ if (details.url == URL) {
+ browser.test.assertEq(0, details.frameId, "root frame ID correct");
+ browser.test.assertEq(-1, details.parentFrameId, "root parent frame ID correct");
+ } else {
+ browser.test.assertEq(0, details.parentFrameId, "parent frame ID correct");
+ browser.test.assertTrue(details.frameId != 0, "frame ID probably okay");
+ }
+
+ browser.test.assertTrue(details.frameId !== undefined, "frameId != undefined");
+ browser.test.assertTrue(details.parentFrameId !== undefined, "parentFrameId != undefined");
+ }
+
+ let listeners = {};
+ for (let event of EVENTS) {
+ listeners[event] = gotEvent.bind(null, event);
+ browser.webNavigation[event].addListener(listeners[event]);
+ }
+
+ browser.test.sendMessage("ready");
+}
+
+const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest";
+const URL = BASE + "/file_WebNavigation_page1.html";
+const FORM_URL = URL + "?";
+const FRAME = BASE + "/file_WebNavigation_page2.html";
+const FRAME2 = BASE + "/file_WebNavigation_page3.html";
+const FRAME_PUSHSTATE = BASE + "/file_WebNavigation_page3_pushState.html";
+const REDIRECT = BASE + "/redirection.sjs";
+const REDIRECTED = BASE + "/dummy_page.html";
+const CLIENT_REDIRECT = BASE + "/file_webNavigation_clientRedirect.html";
+const CLIENT_REDIRECT_HTTPHEADER = BASE + "/file_webNavigation_clientRedirect_httpHeaders.html";
+const FRAME_CLIENT_REDIRECT = BASE + "/file_webNavigation_frameClientRedirect.html";
+const FRAME_REDIRECT = BASE + "/file_webNavigation_frameRedirect.html";
+const FRAME_MANUAL = BASE + "/file_webNavigation_manualSubframe.html";
+const FRAME_MANUAL_PAGE1 = BASE + "/file_webNavigation_manualSubframe_page1.html";
+const FRAME_MANUAL_PAGE2 = BASE + "/file_webNavigation_manualSubframe_page2.html";
+const INVALID_PAGE = "https://invalid.localhost/";
+
+const REQUIRED = [
+ "onBeforeNavigate",
+ "onCommitted",
+ "onDOMContentLoaded",
+ "onCompleted",
+];
+
+var received = [];
+var completedResolve;
+var waitingURL, waitingEvent;
+
+function loadAndWait(win, event, url, script) {
+ received = [];
+ waitingEvent = event;
+ waitingURL = url;
+ dump(`RUN ${script}\n`);
+ script();
+ return new Promise(resolve => { completedResolve = resolve; });
+}
+
+add_task(async function webnav_transitions_props() {
+ function backgroundScriptTransitions() {
+ const EVENTS = [
+ "onCommitted",
+ "onHistoryStateUpdated",
+ "onReferenceFragmentUpdated",
+ "onCompleted",
+ ];
+
+ function gotEvent(event, details) {
+ browser.test.log(`Got ${event} ${details.url} ${details.transitionType} ${details.transitionQualifiers && JSON.stringify(details.transitionQualifiers)}`);
+
+ browser.test.sendMessage("received", {url: details.url, details, event});
+ }
+
+ let listeners = {};
+ for (let event of EVENTS) {
+ listeners[event] = gotEvent.bind(null, event);
+ browser.webNavigation[event].addListener(listeners[event]);
+ }
+
+ browser.test.sendMessage("ready");
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: [
+ "webNavigation",
+ ],
+ },
+ background: backgroundScriptTransitions,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ extension.onMessage("received", ({url, event, details}) => {
+ received.push({url, event, details});
+
+ if (event == waitingEvent && url == waitingURL) {
+ completedResolve();
+ }
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+ info("webnavigation extension loaded");
+
+ let win = window.open();
+
+ await loadAndWait(win, "onCompleted", URL, () => { win.location = URL; });
+
+ // transitionType: reload
+ received = [];
+ await loadAndWait(win, "onCompleted", URL, () => { win.location.reload(); });
+
+ let found = received.find((data) => (data.event == "onCommitted" && data.url == URL));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "reload",
+ "Got the expected 'reload' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers),
+ "transitionQualifiers found in the OnCommitted events");
+ }
+
+ // transitionType: auto_subframe
+ found = received.find((data) => (data.event == "onCommitted" && data.url == FRAME));
+
+ ok(found, "Got the sub-frame onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "auto_subframe",
+ "Got the expected 'auto_subframe' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers),
+ "transitionQualifiers found in the OnCommitted events");
+ }
+
+ // transitionType: form_submit
+ received = [];
+ await loadAndWait(win, "onCompleted", FORM_URL, () => {
+ win.document.querySelector("form").submit();
+ });
+
+ found = received.find((data) => (data.event == "onCommitted" && data.url == FORM_URL));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "form_submit",
+ "Got the expected 'form_submit' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers),
+ "transitionQualifiers found in the OnCommitted events");
+ }
+
+ // transitionQualifier: server_redirect
+ received = [];
+ await loadAndWait(win, "onCompleted", REDIRECTED, () => { win.location = REDIRECT; });
+
+ found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "link",
+ "Got the expected 'link' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers) &&
+ found.details.transitionQualifiers.find((q) => q == "server_redirect"),
+ "Got the expected 'server_redirect' transitionQualifiers in the OnCommitted events");
+ }
+
+ // transitionQualifier: forward_back
+ received = [];
+ await loadAndWait(win, "onCompleted", FORM_URL, () => { win.history.back(); });
+
+ found = received.find((data) => (data.event == "onCommitted" && data.url == FORM_URL));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "link",
+ "Got the expected 'link' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers) &&
+ found.details.transitionQualifiers.find((q) => q == "forward_back"),
+ "Got the expected 'forward_back' transitionQualifiers in the OnCommitted events");
+ }
+
+ // transitionQualifier: client_redirect
+ // (from meta http-equiv tag)
+ received = [];
+ await loadAndWait(win, "onCompleted", REDIRECTED, () => {
+ win.location = CLIENT_REDIRECT;
+ });
+
+ found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "link",
+ "Got the expected 'link' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers) &&
+ found.details.transitionQualifiers.find((q) => q == "client_redirect"),
+ "Got the expected 'client_redirect' transitionQualifiers in the OnCommitted events");
+ }
+
+ // transitionQualifier: client_redirect
+ // (from http headers)
+ received = [];
+ await loadAndWait(win, "onCompleted", REDIRECTED, () => {
+ win.location = CLIENT_REDIRECT_HTTPHEADER;
+ });
+
+ found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "link",
+ "Got the expected 'link' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers) &&
+ found.details.transitionQualifiers.find((q) => q == "client_redirect"),
+ "Got the expected 'client_redirect' transitionQualifiers in the OnCommitted events");
+ }
+
+ // transitionQualifier: client_redirect (sub-frame)
+ // (from meta http-equiv tag)
+ received = [];
+ await loadAndWait(win, "onCompleted", REDIRECTED, () => {
+ win.location = FRAME_CLIENT_REDIRECT;
+ });
+
+ found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "auto_subframe",
+ "Got the expected 'auto_subframe' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers) &&
+ found.details.transitionQualifiers.find((q) => q == "client_redirect"),
+ "Got the expected 'client_redirect' transitionQualifiers in the OnCommitted events");
+ }
+
+ // transitionQualifier: server_redirect (sub-frame)
+ received = [];
+ await loadAndWait(win, "onCompleted", REDIRECTED, () => { win.location = FRAME_REDIRECT; });
+
+ found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECT));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "auto_subframe",
+ "Got the expected 'auto_subframe' transitionType in the OnCommitted event");
+ // TODO BUG 1264936: currently the server_redirect is not detected in sub-frames
+ // once we fix it we can test it here:
+ //
+ // ok(Array.isArray(found.details.transitionQualifiers) &&
+ // found.details.transitionQualifiers.find((q) => q == "server_redirect"),
+ // "Got the expected 'server_redirect' transitionQualifiers in the OnCommitted events");
+ }
+
+ // transitionType: manual_subframe
+ received = [];
+ await loadAndWait(win, "onCompleted", FRAME_MANUAL, () => { win.location = FRAME_MANUAL; });
+ found = received.find((data) => (data.event == "onCommitted" &&
+ data.url == FRAME_MANUAL_PAGE1));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "auto_subframe",
+ "Got the expected 'auto_subframe' transitionType in the OnCommitted event");
+ }
+
+ received = [];
+ await loadAndWait(win, "onCompleted", FRAME_MANUAL_PAGE2, () => {
+ let el = win.document.querySelector("iframe")
+ .contentDocument.querySelector("a");
+ sendMouseEvent({type: "click"}, el, win);
+ });
+
+ found = received.find((data) => (data.event == "onCommitted" &&
+ data.url == FRAME_MANUAL_PAGE2));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ if (AppConstants.MOZ_BUILD_APP === "browser") {
+ is(found.details.transitionType, "manual_subframe",
+ "Got the expected 'manual_subframe' transitionType in the OnCommitted event");
+ } else {
+ is(found.details.transitionType, "auto_subframe",
+ "Got the expected 'manual_subframe' transitionType in the OnCommitted event");
+ }
+ }
+
+ // Test transitions properties on onHistoryStateUpdated events.
+
+ received = [];
+ await loadAndWait(win, "onCompleted", FRAME2, () => { win.location = FRAME2; });
+
+ received = [];
+ await loadAndWait(win, "onHistoryStateUpdated", `${FRAME2}/pushState`, () => {
+ win.history.pushState({}, "History PushState", `${FRAME2}/pushState`);
+ });
+
+ found = received.find((data) => (data.event == "onHistoryStateUpdated" &&
+ data.url == `${FRAME2}/pushState`));
+
+ ok(found, "Got the onHistoryStateUpdated event");
+
+ if (found) {
+ is(typeof found.details.transitionType, "string",
+ "Got transitionType in the onHistoryStateUpdated event");
+ ok(Array.isArray(found.details.transitionQualifiers),
+ "Got transitionQualifiers in the onHistoryStateUpdated event");
+ }
+
+ // Test transitions properties on onReferenceFragmentUpdated events.
+
+ received = [];
+ await loadAndWait(win, "onReferenceFragmentUpdated", `${FRAME2}/pushState#ref2`, () => {
+ win.history.pushState({}, "ReferenceFragment Update", `${FRAME2}/pushState#ref2`);
+ });
+
+ found = received.find((data) => (data.event == "onReferenceFragmentUpdated" &&
+ data.url == `${FRAME2}/pushState#ref2`));
+
+ ok(found, "Got the onReferenceFragmentUpdated event");
+
+ if (found) {
+ is(typeof found.details.transitionType, "string",
+ "Got transitionType in the onReferenceFragmentUpdated event");
+ ok(Array.isArray(found.details.transitionQualifiers),
+ "Got transitionQualifiers in the onReferenceFragmentUpdated event");
+ }
+
+ // cleanup phase
+ win.close();
+
+ await extension.unload();
+ info("webnavigation extension unloaded");
+});
+
+add_task(async function webnav_ordering() {
+ let extensionData = {
+ manifest: {
+ permissions: [
+ "webNavigation",
+ ],
+ },
+ background: backgroundScript,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ extension.onMessage("received", ({url, event}) => {
+ received.push({url, event});
+
+ if (event == waitingEvent && url == waitingURL) {
+ completedResolve();
+ }
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ info("webnavigation extension loaded");
+
+ let win = window.open();
+
+ await loadAndWait(win, "onCompleted", URL, () => { win.location = URL; });
+
+ function checkRequired(url) {
+ for (let event of REQUIRED) {
+ let found = false;
+ for (let r of received) {
+ if (r.url == url && r.event == event) {
+ found = true;
+ }
+ }
+ ok(found, `Received event ${event} from ${url}`);
+ }
+ }
+
+ checkRequired(URL);
+ checkRequired(FRAME);
+
+ function checkBefore(action1, action2) {
+ function find(action) {
+ for (let i = 0; i < received.length; i++) {
+ if (received[i].url == action.url && received[i].event == action.event) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ let index1 = find(action1);
+ let index2 = find(action2);
+ ok(index1 != -1, `Action ${JSON.stringify(action1)} happened`);
+ ok(index2 != -1, `Action ${JSON.stringify(action2)} happened`);
+ ok(index1 < index2, `Action ${JSON.stringify(action1)} happened before ${JSON.stringify(action2)}`);
+ }
+
+ // As required in the webNavigation API documentation:
+ // If a navigating frame contains subframes, its onCommitted is fired before any
+ // of its children's onBeforeNavigate; while onCompleted is fired after
+ // all of its children's onCompleted.
+ checkBefore({url: URL, event: "onCommitted"}, {url: FRAME, event: "onBeforeNavigate"});
+ checkBefore({url: FRAME, event: "onCompleted"}, {url: URL, event: "onCompleted"});
+
+ // As required in the webNAvigation API documentation, check the event sequence:
+ // onBeforeNavigate -> onCommitted -> onDOMContentLoaded -> onCompleted
+ let expectedEventSequence = [
+ "onBeforeNavigate", "onCommitted", "onDOMContentLoaded", "onCompleted",
+ ];
+
+ for (let i = 1; i < expectedEventSequence.length; i++) {
+ let after = expectedEventSequence[i];
+ let before = expectedEventSequence[i - 1];
+ checkBefore({url: URL, event: before}, {url: URL, event: after});
+ checkBefore({url: FRAME, event: before}, {url: FRAME, event: after});
+ }
+
+ await loadAndWait(win, "onCompleted", FRAME2, () => { win.frames[0].location = FRAME2; });
+
+ checkRequired(FRAME2);
+
+ let navigationSequence = [
+ {
+ action: () => { win.frames[0].document.getElementById("elt").click(); },
+ waitURL: `${FRAME2}#ref`,
+ expectedEvent: "onReferenceFragmentUpdated",
+ description: "clicked an anchor link",
+ },
+ {
+ action: () => { win.frames[0].history.pushState({}, "History PushState", `${FRAME2}#ref2`); },
+ waitURL: `${FRAME2}#ref2`,
+ expectedEvent: "onReferenceFragmentUpdated",
+ description: "history.pushState, same pathname, different hash",
+ },
+ {
+ action: () => { win.frames[0].history.pushState({}, "History PushState", `${FRAME2}#ref2`); },
+ waitURL: `${FRAME2}#ref2`,
+ expectedEvent: "onHistoryStateUpdated",
+ description: "history.pushState, same pathname, same hash",
+ },
+ {
+ action: () => {
+ win.frames[0].history.pushState({}, "History PushState", `${FRAME2}?query_param1=value#ref2`);
+ },
+ waitURL: `${FRAME2}?query_param1=value#ref2`,
+ expectedEvent: "onHistoryStateUpdated",
+ description: "history.pushState, same pathname, same hash, different query params",
+ },
+ {
+ action: () => {
+ win.frames[0].history.pushState({}, "History PushState", `${FRAME2}?query_param2=value#ref3`);
+ },
+ waitURL: `${FRAME2}?query_param2=value#ref3`,
+ expectedEvent: "onHistoryStateUpdated",
+ description: "history.pushState, same pathname, different hash, different query params",
+ },
+ {
+ action: () => { win.frames[0].history.pushState(null, "History PushState", FRAME_PUSHSTATE); },
+ waitURL: FRAME_PUSHSTATE,
+ expectedEvent: "onHistoryStateUpdated",
+ description: "history.pushState, different pathname",
+ },
+ ];
+
+ for (let navigation of navigationSequence) {
+ let {expectedEvent, waitURL, action, description} = navigation;
+ info(`Waiting ${expectedEvent} from ${waitURL} - ${description}`);
+ await loadAndWait(win, expectedEvent, waitURL, action);
+ info(`Received ${expectedEvent} from ${waitURL} - ${description}`);
+ }
+
+ for (let i = navigationSequence.length - 1; i > 0; i--) {
+ let {waitURL: fromURL, expectedEvent} = navigationSequence[i];
+ let {waitURL} = navigationSequence[i - 1];
+ info(`Waiting ${expectedEvent} from ${waitURL} - history.back() from ${fromURL} to ${waitURL}`);
+ await loadAndWait(win, expectedEvent, waitURL, () => { win.frames[0].history.back(); });
+ info(`Received ${expectedEvent} from ${waitURL} - history.back() from ${fromURL} to ${waitURL}`);
+ }
+
+ for (let i = 0; i < navigationSequence.length - 1; i++) {
+ let {waitURL: fromURL} = navigationSequence[i];
+ let {waitURL, expectedEvent} = navigationSequence[i + 1];
+ info(`Waiting ${expectedEvent} from ${waitURL} - history.forward() from ${fromURL} to ${waitURL}`);
+ await loadAndWait(win, expectedEvent, waitURL, () => { win.frames[0].history.forward(); });
+ info(`Received ${expectedEvent} from ${waitURL} - history.forward() from ${fromURL} to ${waitURL}`);
+ }
+
+ win.close();
+
+ await extension.unload();
+ info("webnavigation extension unloaded");
+});
+
+add_task(async function webnav_error_event() {
+ function backgroundScriptErrorEvent() {
+ browser.webNavigation.onErrorOccurred.addListener((details) => {
+ browser.test.log(`Got onErrorOccurred ${details.url} ${details.error}`);
+
+ browser.test.sendMessage("received", {url: details.url, details, event: "onErrorOccurred"});
+ });
+
+ browser.test.sendMessage("ready");
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: [
+ "webNavigation",
+ ],
+ },
+ background: backgroundScriptErrorEvent,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ extension.onMessage("received", ({url, event, details}) => {
+ received.push({url, event, details});
+
+ if (event == waitingEvent && url == waitingURL) {
+ completedResolve();
+ }
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+ info("webnavigation extension loaded");
+
+ let win = window.open();
+
+ received = [];
+ await loadAndWait(win, "onErrorOccurred", INVALID_PAGE, () => { win.location = INVALID_PAGE; });
+
+ let found = received.find((data) => (data.event == "onErrorOccurred" &&
+ data.url == INVALID_PAGE));
+
+ ok(found, "Got the onErrorOccurred event");
+
+ if (found) {
+ ok(found.details.error.match(/Error code [0-9]+/),
+ "Got the expected error string in the onErrorOccurred event");
+ }
+
+ // cleanup phase
+ win.close();
+
+ await extension.unload();
+ info("webnavigation extension unloaded");
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html
new file mode 100644
index 0000000000..19cb6539d7
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html
@@ -0,0 +1,313 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_webnav_unresolved_uri_on_expected_URI_scheme() {
+ function background() {
+ let listeners = [];
+
+ function cleanupTestListeners() {
+ browser.test.log(`Cleanup previous test event listeners`);
+ for (let {event, listener} of listeners.splice(0)) {
+ browser.webNavigation[event].removeListener(listener);
+ }
+ }
+
+ function createTestListener(event, fail, urlFilter) {
+ return new Promise(resolve => {
+ function listener(details) {
+ let log = JSON.stringify({url: details.url, urlFilter});
+ if (fail) {
+ browser.test.fail(`Got an unexpected ${event} on the failure listener: ${log}`);
+ } else {
+ browser.test.succeed(`Got the expected ${event} on the success listener: ${log}`);
+ }
+
+ resolve();
+ }
+
+ browser.webNavigation[event].addListener(listener, {url: urlFilter});
+ listeners.push({event, listener});
+ });
+ }
+
+ browser.test.onMessage.addListener((msg, events, data) => {
+ if (msg !== "test-filters") {
+ return;
+ }
+
+ let promises = [];
+
+ for (let {okFilter, failFilter} of data.filters) {
+ for (let event of events) {
+ promises.push(
+ Promise.race([
+ createTestListener(event, false, okFilter),
+ createTestListener(event, true, failFilter),
+ ]));
+ }
+ }
+
+ Promise.all(promises).catch(e => {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ }).then(() => {
+ cleanupTestListeners();
+ browser.test.sendMessage("test-filter-next");
+ });
+
+ browser.test.sendMessage("test-filter-ready");
+ });
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: [
+ "webNavigation",
+ ],
+ },
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ let win = window.open();
+
+ let testFilterScenarios = [
+ {
+ url: "https://example.net/browser",
+ filters: [
+ // schemes
+ {
+ okFilter: [{schemes: ["https"]}],
+ failFilter: [{schemes: ["http"]}],
+ },
+ // ports
+ {
+ okFilter: [{ports: [80, 22, 443]}],
+ failFilter: [{ports: [81, 82, 83]}],
+ },
+ {
+ okFilter: [{ports: [22, 443, [10, 80]]}],
+ failFilter: [{ports: [22, 23, [81, 100]]}],
+ },
+ // multiple criteria in a single filter:
+ // if one of the criteria is not verified, the event should not be received.
+ {
+ okFilter: [{schemes: ["https"], ports: [80, 22, 443]}],
+ failFilter: [{schemes: ["https"], ports: [81, 82, 83]}],
+ },
+ {
+ okFilter: [{hostEquals: "example.net", ports: [80, 22, 443]}],
+ failFilter: [{hostEquals: "example.org", ports: [80, 22, 443]}],
+ },
+ // multiple urlFilters on the same listener
+ // if at least one of the criteria is verified, the event should be received.
+ {
+ okFilter: [{schemes: ["http"]}, {ports: [80, 22, 443]}],
+ failFilter: [{schemes: ["http"]}, {ports: [81, 82, 83]}],
+ },
+ ],
+ },
+ {
+ url: "https://example.net/browser?param=1#ref",
+ filters: [
+ // host: Equals, Contains, Prefix, Suffix
+ {
+ okFilter: [{hostEquals: "example.net"}],
+ failFilter: [{hostEquals: "example.com"}],
+ },
+ {
+ okFilter: [{hostContains: ".example"}],
+ failFilter: [{hostContains: ".www"}],
+ },
+ {
+ okFilter: [{hostPrefix: "example"}],
+ failFilter: [{hostPrefix: "www"}],
+ },
+ {
+ okFilter: [{hostSuffix: "net"}],
+ failFilter: [{hostSuffix: "com"}],
+ },
+ // path: Equals, Contains, Prefix, Suffix
+ {
+ okFilter: [{pathEquals: "/browser"}],
+ failFilter: [{pathEquals: "/"}],
+ },
+ {
+ okFilter: [{pathContains: "brow"}],
+ failFilter: [{pathContains: "tool"}],
+ },
+ {
+ okFilter: [{pathPrefix: "/bro"}],
+ failFilter: [{pathPrefix: "/tool"}],
+ },
+ {
+ okFilter: [{pathSuffix: "wser"}],
+ failFilter: [{pathSuffix: "kit"}],
+ },
+ // query: Equals, Contains, Prefix, Suffix
+ {
+ okFilter: [{queryEquals: "param=1"}],
+ failFilter: [{queryEquals: "wrongparam=2"}],
+ },
+ {
+ okFilter: [{queryContains: "param"}],
+ failFilter: [{queryContains: "wrongparam"}],
+ },
+ {
+ okFilter: [{queryPrefix: "param="}],
+ failFilter: [{queryPrefix: "wrong"}],
+ },
+ {
+ okFilter: [{querySuffix: "=1"}],
+ failFilter: [{querySuffix: "=2"}],
+ },
+ // urlMatches, originAndPathMatches
+ {
+ okFilter: [{urlMatches: "example.net/.*\?param=1"}],
+ failFilter: [{urlMatches: "example.net/.*\?wrongparam=2"}],
+ },
+ {
+ okFilter: [{originAndPathMatches: "example.net\/browser"}],
+ failFilter: [{originAndPathMatches: "example.net/.*\?param=1"}],
+ },
+ ],
+ },
+ ];
+
+ info("WebNavigation event filters test scenarios starting...");
+
+ const EVENTS = [
+ "onBeforeNavigate",
+ "onCommitted",
+ "onDOMContentLoaded",
+ "onCompleted",
+ ];
+
+ for (let data of testFilterScenarios) {
+ info(`Prepare the new test scenario: ${JSON.stringify(data)}`);
+
+ win.location = "about:blank";
+
+ // Wait for the about:blank load to finish before continuing, in case this
+ // load is causing a process switch back into our process.
+ await SimpleTest.promiseWaitForCondition(() => {
+ try {
+ return win.location.href == "about:blank" &&
+ win.document.readyState == "complete";
+ } catch (e) {
+ return false;
+ }
+ });
+
+ extension.sendMessage("test-filters", EVENTS, data);
+ await extension.awaitMessage("test-filter-ready");
+
+ info(`Loading the test url: ${data.url}`);
+ win.location = data.url;
+
+ await extension.awaitMessage("test-filter-next");
+
+ info("Test scenario completed. Moving to the next test scenario.");
+ }
+
+ info("WebNavigation event filters test onReferenceFragmentUpdated scenario starting...");
+
+ const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest";
+ let url = BASE + "/file_WebNavigation_page3.html";
+
+ let okFilter = [{urlContains: "_page3.html"}];
+ let failFilter = [{ports: [444]}];
+ let data = {filters: [{okFilter, failFilter}]};
+ let event = "onCompleted";
+
+ info(`Loading the initial test url: ${url}`);
+ extension.sendMessage("test-filters", [event], data);
+
+ await extension.awaitMessage("test-filter-ready");
+ win.location = url;
+ await extension.awaitMessage("test-filter-next");
+
+ event = "onReferenceFragmentUpdated";
+ extension.sendMessage("test-filters", [event], data);
+
+ await extension.awaitMessage("test-filter-ready");
+ win.location = url + "#ref1";
+ await extension.awaitMessage("test-filter-next");
+
+ info("WebNavigation event filters test onHistoryStateUpdated scenario starting...");
+
+ event = "onHistoryStateUpdated";
+ extension.sendMessage("test-filters", [event], data);
+ await extension.awaitMessage("test-filter-ready");
+
+ win.history.pushState({}, "", BASE + "/pushState_page3.html");
+ await extension.awaitMessage("test-filter-next");
+
+ // TODO: add additional specific tests for the other webNavigation events:
+ // onErrorOccurred (and onCreatedNavigationTarget on supported)
+
+ info("WebNavigation event filters test scenarios completed.");
+
+ await extension.unload();
+
+ win.close();
+});
+
+add_task(async function test_webnav_empty_filter_validation_error() {
+ function background() {
+ let catchedException;
+
+ try {
+ browser.webNavigation.onCompleted.addListener(
+ // Empty callback (not really used)
+ () => {},
+ // Empty filter (which should raise a validation error exception).
+ {url: []}
+ );
+ } catch (e) {
+ catchedException = e;
+ browser.test.log(`Got an exception`);
+ }
+
+ if (catchedException &&
+ catchedException.message.includes("Type error for parameter filters") &&
+ catchedException.message.includes("Array requires at least 1 items; you have 0")) {
+ browser.test.notifyPass("webNav.emptyFilterValidationError");
+ } else {
+ browser.test.notifyFail("webNav.emptyFilterValidationError");
+ }
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webNavigation",
+ ],
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await extension.awaitFinish("webNav.emptyFilterValidationError");
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html
new file mode 100644
index 0000000000..45147365ee
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html
@@ -0,0 +1,105 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function webnav_test_incognito() {
+ // Monitor will fail if it gets any event.
+ let monitor = ExtensionTestUtils.loadExtension({
+ manifest: {
+ "permissions": ["webNavigation", "*://mochi.test/*"],
+ },
+ background() {
+ const EVENTS = [
+ "onTabReplaced",
+ "onBeforeNavigate",
+ "onCommitted",
+ "onDOMContentLoaded",
+ "onCompleted",
+ "onErrorOccurred",
+ "onReferenceFragmentUpdated",
+ "onHistoryStateUpdated",
+ ];
+
+ function onEvent(event, details) {
+ browser.test.fail(`not_allowed - Got ${event} ${details.url} ${details.frameId} ${details.parentFrameId}`);
+ }
+
+ let listeners = {};
+ for (let event of EVENTS) {
+ listeners[event] = onEvent.bind(null, event);
+ browser.webNavigation[event].addListener(listeners[event]);
+ }
+
+ browser.test.onMessage.addListener(async (message, tabId) => {
+ // try to access the private window
+ await browser.test.assertRejects(browser.webNavigation.getAllFrames({tabId}),
+ /Invalid tab ID/,
+ "should not be able to get incognito frames");
+ await browser.test.assertRejects(browser.webNavigation.getFrame({tabId, frameId: 0}),
+ /Invalid tab ID/,
+ "should not be able to get incognito frames");
+ browser.test.notifyPass("completed");
+ });
+ },
+ });
+
+ // extension loads a private window and waits for the onCompleted event.
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ permissions: ["tabs", "webNavigation", "*://mochi.test/*"],
+ },
+ async background() {
+ const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest";
+ const url = BASE + "/file_WebNavigation_page1.html";
+ let window;
+
+ browser.webNavigation.onCompleted.addListener(async (details) => {
+ if (details.url !== url) {
+ return;
+ }
+ browser.test.log(`spanning - Got onCompleted ${details.url} ${details.frameId} ${details.parentFrameId}`);
+ browser.test.sendMessage("completed");
+ });
+ browser.test.onMessage.addListener(async () => {
+ await browser.windows.remove(window.id);
+ browser.test.notifyPass("done");
+ });
+ window = await browser.windows.create({url, incognito: true});
+ let tabs = await browser.tabs.query({active: true, windowId: window.id});
+ browser.test.sendMessage("tabId", tabs[0].id);
+ },
+ });
+
+ await monitor.startup();
+ await extension.startup();
+
+ await extension.awaitMessage("completed");
+ let tabId = await extension.awaitMessage("tabId");
+
+ await monitor.sendMessage("tab", tabId);
+ await monitor.awaitFinish("completed");
+
+ await extension.sendMessage("close");
+ await extension.awaitFinish("done");
+
+ await extension.unload();
+ await monitor.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html
new file mode 100644
index 0000000000..b28cbb7635
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html
@@ -0,0 +1,131 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+<script>
+"use strict";
+
+// Check that the windowId and tabId filter work as expected in the webRequest
+// and proxy API:
+// - A non-matching windowId / tabId listener won't trigger events.
+// - A matching tabId from a tab triggers the event.
+// - A matching windowId from a tab triggers the event.
+// (unlike test_ext_webrequest_filter.html, this also works on Android)
+// - Requests from background pages can be matched with windowId and tabId -1.
+add_task(async function test_filter_tabId_and_windowId() {
+ async function tabScript() {
+ let pendingExpectations = new Set();
+ // Helper to detect completion of expected requests.
+ function watchExpected(filter, desc) {
+ desc += ` - ${JSON.stringify(filter)}`;
+ const DESC_PROXY = `${desc} (proxy)`;
+ const DESC_WEBREQUEST = `${desc} (webRequest)`;
+ pendingExpectations.add(DESC_PROXY);
+ pendingExpectations.add(DESC_WEBREQUEST);
+ browser.proxy.onRequest.addListener(() => {
+ pendingExpectations.delete(DESC_PROXY);
+ }, filter);
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ pendingExpectations.delete(DESC_WEBREQUEST);
+ },
+ filter,
+ ["blocking"]
+ );
+ }
+
+ // Helper to detect unexpected requests.
+ function watchUnexpected(filter, desc) {
+ desc += ` - ${JSON.stringify(filter)}`;
+ browser.proxy.onRequest.addListener(() => {
+ browser.test.fail(`${desc} - unexpected proxy event`);
+ }, filter);
+ browser.webRequest.onBeforeRequest.addListener(() => {
+ browser.test.fail(`${desc} - unexpected webRequest event`);
+ }, filter);
+ }
+
+ function registerExpectations(url, windowId, tabId) {
+ const urls = [url];
+ watchUnexpected({ urls, windowId: 0 }, "non-matching windowId");
+ watchUnexpected({ urls, tabId: 0 }, "non-matching tabId");
+
+ watchExpected({ urls, windowId }, "windowId matches");
+ watchExpected({ urls, tabId }, "tabId matches");
+ }
+
+ try {
+ let { windowId, tabId } = await browser.runtime.sendMessage("getIds");
+ browser.test.log(`Dummy tab has: tabId=${tabId} windowId=${windowId}`);
+ registerExpectations("http://example.com/?tab", windowId, tabId);
+ registerExpectations("http://example.com/?bg", -1, -1);
+
+ // Call an API method implemented in the parent process to ensure that
+ // the listeners have been registered (workaround for bug 1300234).
+ // There is a .catch() at the end because the call is rejected on Android.
+ await browser.proxy.settings.get({}).catch(() => {});
+
+ browser.test.log("Triggering request from background page.");
+ await browser.runtime.sendMessage("triggerBackgroundRequest");
+
+ browser.test.log("Triggering request from tab.");
+ await fetch("http://example.com/?tab");
+
+ browser.test.assertEq(0, pendingExpectations.size, "got all events");
+ for (let description of pendingExpectations) {
+ browser.test.fail(`Event not observed: ${description}`);
+ }
+ } catch (e) {
+ browser.test.fail(`Unexpected test failure: ${e} :: ${e.stack}`);
+ }
+ browser.runtime.sendMessage("testCompleted");
+ }
+
+ function background() {
+ browser.runtime.onMessage.addListener(async (msg, sender) => {
+ if (msg === "getIds") {
+ return { windowId: sender.tab.windowId, tabId: sender.tab.id };
+ }
+ if (msg === "triggerBackgroundRequest") {
+ await fetch("http://example.com/?bg");
+ }
+ if (msg === "testCompleted") {
+ await browser.tabs.remove(sender.tab.id);
+ browser.test.sendMessage("testCompleted");
+ }
+ });
+ browser.tabs.create({
+ url: browser.runtime.getURL("tab.html"),
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "proxy",
+ "webRequest",
+ "webRequestBlocking",
+ "http://example.com/*",
+ ],
+ },
+ background,
+ files: {
+ "tab.html": `<!DOCTYPE html><script src="tab.js"><\/script>`,
+ "tab.js": tabScript,
+ },
+ });
+ await extension.startup();
+
+ await extension.awaitMessage("testCompleted");
+ await extension.unload();
+});
+</script>
+</head>
+<body>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html
new file mode 100644
index 0000000000..f260f040a1
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html
@@ -0,0 +1,181 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head_webrequest.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+// This file defines content scripts.
+
+let baseUrl = "http://mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/authenticate.sjs";
+function testXHR(url) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", url);
+ xhr.onload = resolve;
+ xhr.onabort = reject;
+ xhr.onerror = reject;
+ xhr.send();
+ });
+}
+
+function getAuthHandler(result, blocking = true) {
+ function background(result) {
+ browser.webRequest.onAuthRequired.addListener((details) => {
+ browser.test.succeed(`authHandler.onAuthRequired called with ${details.requestId} ${details.url} result ${JSON.stringify(result)}`);
+ browser.test.sendMessage("onAuthRequired");
+ return result;
+ }, {urls: ["*://mochi.test/*"]}, ["blocking"]);
+ browser.webRequest.onCompleted.addListener((details) => {
+ browser.test.succeed(`authHandler.onCompleted called with ${details.requestId} ${details.url}`);
+ browser.test.sendMessage("onCompleted");
+ }, {urls: ["*://mochi.test/*"]});
+ browser.webRequest.onErrorOccurred.addListener((details) => {
+ browser.test.succeed(`authHandler.onErrorOccurred called with ${details.requestId} ${details.url}`);
+ browser.test.sendMessage("onErrorOccurred");
+ }, {urls: ["*://mochi.test/*"]});
+ }
+
+ let permissions = [
+ "webRequest",
+ "*://mochi.test/*",
+ ];
+ if (blocking) {
+ permissions.push("webRequestBlocking");
+ }
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions,
+ },
+ background: `(${background})(${JSON.stringify(result)})`,
+ });
+}
+
+add_task(async function test_webRequest_auth_nonblocking_forwardAuthProvider() {
+ // The chrome script sets up a default auth handler on the channel, the
+ // extension does not return anything in the authRequred call. We should
+ // get the call in the extension first, then in the chrome code where we
+ // cancel the request to avoid dealing with the prompt dialog here. The test
+ // is to ensure that WebRequest calls the previous notificationCallbacks
+ // if the authorization is not handled by the onAuthRequired handler.
+
+ let chromeScript = SpecialPowers.loadChromeScript(() => {
+ /* eslint-env mozilla/chrome-script */
+ let observer = channel => {
+ if (!(channel instanceof Ci.nsIHttpChannel && channel.URI.host === "mochi.test" &&
+ channel.URI.spec.includes("authenticate.sjs"))) {
+ return;
+ }
+ Services.obs.removeObserver(observer, "http-on-modify-request");
+ channel.notificationCallbacks = {
+ QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor",
+ "nsIAuthPromptProvider",
+ "nsIAuthPrompt2"]),
+ getInterface: ChromeUtils.generateQI(["nsIAuthPromptProvider",
+ "nsIAuthPrompt2"]),
+ promptAuth(channel, level, authInfo) {
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ },
+ getAuthPrompt(reason, iid) {
+ return this;
+ },
+ asyncPromptAuth(channel, callback, context, level, authInfo) {
+ // We just cancel here, we're only ensuring that non-webrequest
+ // notificationcallbacks get called if webrequest doesn't handle it.
+ Promise.resolve().then(() => {
+ callback.onAuthCancelled(context, false);
+ channel.cancel(Cr.NS_BINDING_ABORTED);
+ sendAsyncMessage("callback-complete");
+ });
+ },
+ };
+ };
+ Services.obs.addObserver(observer, "http-on-modify-request");
+ sendAsyncMessage("chrome-ready");
+ });
+ await chromeScript.promiseOneMessage("chrome-ready");
+ let callbackComplete = chromeScript.promiseOneMessage("callback-complete");
+
+ let handlingExt = getAuthHandler();
+ await handlingExt.startup();
+
+ await Assert.rejects(testXHR(`${baseUrl}?realm=auth_nonblocking_forwardAuth&user=auth_nonblocking_forwardAuth&pass=auth_nonblocking_forwardAuth`),
+ ProgressEvent,
+ "caught rejected xhr");
+
+ await callbackComplete;
+ await handlingExt.awaitMessage("onAuthRequired");
+ // We expect onErrorOccurred because the "default" authprompt above cancelled
+ // the auth request to avoid a dialog.
+ await handlingExt.awaitMessage("onErrorOccurred");
+ await handlingExt.unload();
+ chromeScript.destroy();
+});
+
+add_task(async function test_webRequest_auth_nonblocking_forwardAuthPrompt2() {
+ // The chrome script sets up a default auth handler on the channel, the
+ // extension does not return anything in the authRequred call. We should
+ // get the call in the extension first, then in the chrome code where we
+ // cancel the request to avoid dealing with the prompt dialog here. The test
+ // is to ensure that WebRequest calls the previous notificationCallbacks
+ // if the authorization is not handled by the onAuthRequired handler.
+
+ let chromeScript = SpecialPowers.loadChromeScript(() => {
+ /* eslint-env mozilla/chrome-script */
+ let observer = channel => {
+ if (!(channel instanceof Ci.nsIHttpChannel && channel.URI.host === "mochi.test" &&
+ channel.URI.spec.includes("authenticate.sjs"))) {
+ return;
+ }
+ Services.obs.removeObserver(observer, "http-on-modify-request");
+ channel.notificationCallbacks = {
+ QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor",
+ "nsIAuthPrompt2"]),
+ getInterface: ChromeUtils.generateQI(["nsIAuthPrompt2"]),
+ promptAuth(request, level, authInfo) {
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ },
+ asyncPromptAuth(request, callback, context, level, authInfo) {
+ // We just cancel here, we're only ensuring that non-webrequest
+ // notificationcallbacks get called if webrequest doesn't handle it.
+ Promise.resolve().then(() => {
+ request.cancel(Cr.NS_BINDING_ABORTED);
+ sendAsyncMessage("callback-complete");
+ });
+ },
+ };
+ };
+ Services.obs.addObserver(observer, "http-on-modify-request");
+ sendAsyncMessage("chrome-ready");
+ });
+ await chromeScript.promiseOneMessage("chrome-ready");
+ let callbackComplete = chromeScript.promiseOneMessage("callback-complete");
+
+ let handlingExt = getAuthHandler();
+ await handlingExt.startup();
+
+ await Assert.rejects(testXHR(`${baseUrl}?realm=auth_nonblocking_forwardAuthPromptProvider&user=auth_nonblocking_forwardAuth&pass=auth_nonblocking_forwardAuth`),
+ ProgressEvent,
+ "caught rejected xhr");
+
+ await callbackComplete;
+ await handlingExt.awaitMessage("onAuthRequired");
+ // We expect onErrorOccurred because the "default" authprompt above cancelled
+ // the auth request to avoid a dialog.
+ await handlingExt.awaitMessage("onErrorOccurred");
+ await handlingExt.unload();
+ chromeScript.destroy();
+});
+</script>
+</head>
+<body>
+<div id="test">Authorization Test</div>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html
new file mode 100644
index 0000000000..86cec62fb4
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html
@@ -0,0 +1,120 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_webRequest_serviceworker_events() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.serviceWorkers.testing.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "<all_urls>",
+ ],
+ },
+ background() {
+ let eventNames = new Set([
+ "onBeforeRequest",
+ "onBeforeSendHeaders",
+ "onSendHeaders",
+ "onHeadersReceived",
+ "onResponseStarted",
+ "onCompleted",
+ "onErrorOccurred",
+ ]);
+
+ function listener(name, details) {
+ browser.test.assertTrue(eventNames.has(name), `received ${name}`);
+ eventNames.delete(name);
+ if (name == "onCompleted") {
+ eventNames.delete("onErrorOccurred");
+ } else if (name == "onErrorOccurred") {
+ eventNames.delete("onCompleted");
+ }
+ if (eventNames.size == 0) {
+ browser.test.sendMessage("done");
+ }
+ }
+
+ for (let name of eventNames) {
+ browser.webRequest[name].addListener(
+ listener.bind(null, name),
+ {urls: ["https://example.com/*"]}
+ );
+ }
+ },
+ });
+
+ await extension.startup();
+ let registration = await navigator.serviceWorker.register("webrequest_worker.js", {scope: "."});
+ await waitForState(registration.installing, "activated");
+ await extension.awaitMessage("done");
+ await registration.unregister();
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_background_events() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "<all_urls>",
+ ],
+ },
+ background() {
+ let eventNames = new Set([
+ "onBeforeRequest",
+ "onBeforeSendHeaders",
+ "onSendHeaders",
+ "onHeadersReceived",
+ "onResponseStarted",
+ "onCompleted",
+ ]);
+
+ function listener(name, details) {
+ browser.test.assertTrue(eventNames.has(name), `received ${name}`);
+ eventNames.delete(name);
+
+ if (eventNames.size === 0) {
+ browser.test.assertEq("xmlhttprequest", details.type, "correct type for fetch [see bug 1366710]");
+ browser.test.assertEq(0, eventNames.size, "messages received");
+ browser.test.sendMessage("done");
+ }
+ }
+
+ for (let name of eventNames) {
+ browser.webRequest[name].addListener(
+ listener.bind(null, name),
+ {urls: ["https://example.com/*"]}
+ );
+ }
+
+ fetch("https://example.com/example.txt").then(() => {
+ browser.test.succeed("Fetch succeeded.");
+ }, () => {
+ browser.test.fail("fetch received");
+ browser.test.sendMessage("done");
+ });
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html
new file mode 100644
index 0000000000..9d57d55681
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html
@@ -0,0 +1,445 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head_webrequest.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+const expectedBaseProps = {
+ // On Desktop builds, if "browse.chrome.guess_favicon" is set to true,
+ // a favicon requests may be triggered at a random time while the test
+ // cases are running, we include it the ignore list by default to prevent
+ // intermittent failures (e.g. see Bug 1733781 and Bug 1633189).
+ ignore: ["favicon.ico"],
+};
+
+function promiseWindowEvent(name, accept) {
+ return new Promise(resolve => {
+ window.addEventListener(name, function listener(event) {
+ if (event.data !== accept) {
+ return;
+ }
+ window.removeEventListener(name, listener);
+ resolve(event);
+ });
+ });
+}
+
+if (AppConstants.platform === "android") {
+ SimpleTest.requestLongerTimeout(3);
+}
+
+let extension;
+add_task(async function setup() {
+ // Clear the image cache, since it gets in the way otherwise.
+ let imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools);
+ let cache = imgTools.getImgCacheForDocument(document);
+ cache.clearCache(false);
+ await SpecialPowers.spawnChrome([], async () => {
+ Services.cache2.clear();
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["network.http.rcwn.enabled", false]],
+ });
+
+ extension = makeExtension();
+ await extension.startup();
+});
+
+// expect is a set of test values used by the background script.
+//
+// type: type of request action
+// events: optional, If defined only the events listed are expected for the
+// request. If undefined, all events except onErrorOccurred
+// and onBeforeRedirect are expected. Must be in order received.
+// redirect: url to redirect to during onBeforeSendHeaders
+// status: number expected status during onHeadersReceived, 200 default
+// cancel: event in which we return cancel=true. cancelled message is sent.
+// cached: expected fromCache value, default is false, checked in onCompletion
+// headers: request or response headers to modify
+// origin: The expected originUrl, a default origin can be passed for all files
+
+add_task(async function test_webRequest_links() {
+ let expect = {
+ "file_style_bad.css": {
+ type: "stylesheet",
+ events: ["onBeforeRequest", "onErrorOccurred"],
+ cancel: "onBeforeRequest",
+ },
+ "file_style_redirect.css": {
+ type: "stylesheet",
+ events: ["onBeforeRequest", "onBeforeSendHeaders", "onBeforeRedirect"],
+ optional_events: ["onHeadersReceived"],
+ redirect: "file_style_good.css",
+ },
+ "file_style_good.css": {
+ type: "stylesheet",
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ addStylesheet("file_style_bad.css");
+ await extension.awaitMessage("cancelled");
+ // we redirect to style_good which completes the test
+ addStylesheet(`file_style_redirect.css?nocache=${Math.random()}`);
+ await extension.awaitMessage("done");
+});
+
+add_task(async function test_webRequest_images() {
+ let expect = {
+ "file_image_bad.png": {
+ type: "image",
+ events: ["onBeforeRequest", "onErrorOccurred"],
+ cancel: "onBeforeRequest",
+ },
+ "file_image_redirect.png": {
+ type: "image",
+ events: ["onBeforeRequest", "onBeforeSendHeaders", "onBeforeRedirect"],
+ optional_events: ["onHeadersReceived"],
+ redirect: "file_image_good.png",
+ },
+ "file_image_good.png": {
+ type: "image",
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ addImage("file_image_bad.png");
+ await extension.awaitMessage("cancelled");
+ // we redirect to image_good which completes the test
+ addImage("file_image_redirect.png");
+ await extension.awaitMessage("done");
+});
+
+add_task(async function test_webRequest_scripts() {
+ let expect = {
+ "file_script_bad.js": {
+ type: "script",
+ events: ["onBeforeRequest", "onErrorOccurred"],
+ cancel: "onBeforeRequest",
+ },
+ "file_script_redirect.js": {
+ type: "script",
+ events: ["onBeforeRequest", "onBeforeSendHeaders", "onBeforeRedirect"],
+ optional_events: ["onHeadersReceived"],
+ redirect: "file_script_good.js",
+ },
+ "file_script_good.js": {
+ type: "script",
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ let message = promiseWindowEvent("message", "test1");
+ addScript("file_script_bad.js");
+ await extension.awaitMessage("cancelled");
+ // we redirect to script_good which completes the test
+ addScript("file_script_redirect.js?q=test1");
+ await extension.awaitMessage("done");
+
+ is((await message).data, "test1", "good script ran");
+});
+
+add_task(async function test_webRequest_xhr_get() {
+ let expect = {
+ "file_script_xhr.js": {
+ type: "script",
+ },
+ "xhr_resource": {
+ status: 404,
+ type: "xmlhttprequest",
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ addScript("file_script_xhr.js");
+ await extension.awaitMessage("done");
+});
+
+add_task(async function test_webRequest_nonexistent() {
+ let expect = {
+ "nonexistent_script_url.js": {
+ status: 404,
+ type: "script",
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ addScript("nonexistent_script_url.js");
+ await extension.awaitMessage("done");
+});
+
+add_task(async function test_webRequest_checkCached() {
+ let expect = {
+ "file_image_good.png": {
+ type: "image",
+ cached: true,
+ },
+ "file_script_good.js": {
+ type: "script",
+ cached: true,
+ },
+ "file_style_good.css": {
+ type: "stylesheet",
+ cached: false,
+ },
+ "nonexistent_script_url.js": {
+ status: 404,
+ type: "script",
+ cached: false,
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ let message = promiseWindowEvent("message", "test1");
+
+ addImage("file_image_good.png");
+ addScript("file_script_good.js?q=test1");
+
+ is((await message).data, "test1", "good script ran");
+
+ addStylesheet(`file_style_good.css?nocache=${Math.random()}`);
+ addScript("nonexistent_script_url.js");
+ await extension.awaitMessage("done");
+});
+
+add_task(async function test_webRequest_headers() {
+ let expect = {
+ "file_script_nonexistent.js": {
+ type: "script",
+ status: 404,
+ headers: {
+ request: {
+ add: {
+ "X-WebRequest-request": "text",
+ "X-WebRequest-request-binary": "binary",
+ },
+ modify: {
+ "user-agent": "WebRequest",
+ },
+ remove: [
+ "referer",
+ ],
+ },
+ response: {
+ add: {
+ "X-WebRequest-response": "text",
+ "X-WebRequest-response-binary": "binary",
+ },
+ modify: {
+ "server": "WebRequest",
+ "content-type": "text/html; charset=utf-8",
+ },
+ remove: [
+ "connection",
+ ],
+ },
+ },
+ completion: "onCompleted",
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ addScript("file_script_nonexistent.js");
+ await extension.awaitMessage("done");
+});
+
+add_task(async function test_webRequest_tabId() {
+ function background() {
+ let tab;
+ browser.tabs.onCreated.addListener(newTab => {
+ tab = newTab;
+ });
+
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "close-tab") {
+ browser.tabs.remove(tab.id);
+ browser.test.sendMessage("tab-closed");
+ }
+ });
+ }
+
+ let tabExt = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "tabs",
+ ],
+ },
+ background,
+ });
+ await tabExt.startup();
+
+ let linkUrl = `file_WebRequest_page3.html?trigger=a&nocache=${Math.random()}`;
+ let expect = {
+ "file_WebRequest_page3.html": {
+ type: "main_frame",
+ },
+ };
+
+ extension.sendMessage("set-expected", {
+ ...expectedBaseProps,
+ expect,
+ origin: location.href,
+ });
+ await extension.awaitMessage("continue");
+ let a = addLink(linkUrl);
+ a.click();
+ await extension.awaitMessage("done");
+
+ let closed = tabExt.awaitMessage("tab-closed");
+ tabExt.sendMessage("close-tab");
+ await closed;
+
+ await tabExt.unload();
+});
+
+add_task(async function test_webRequest_tabId_browser() {
+ async function background(url) {
+ let tabId;
+ browser.test.onMessage.addListener(async (msg, expected) => {
+ if (msg == "create") {
+ let tab = await browser.tabs.create({url});
+ tabId = tab.id;
+ return;
+ }
+ if (msg == "done") {
+ await browser.tabs.remove(tabId);
+ browser.test.sendMessage("done");
+ }
+ });
+ browser.test.sendMessage("origin", browser.runtime.getURL("/"));
+ }
+
+ let pageUrl = `${SimpleTest.getTestFileURL("file_sample.html")}?nocache=${Math.random()}`;
+ let tabExt = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "tabs",
+ ],
+ },
+ background: `(${background})('${pageUrl}')`,
+ });
+
+ let expect = {
+ "file_sample.html": {
+ type: "main_frame",
+ },
+ };
+
+ await tabExt.startup();
+ let origin = await tabExt.awaitMessage("origin");
+
+ // expecting origin == extension baseUrl
+ extension.sendMessage("set-expected", {
+ ...expectedBaseProps,
+ expect,
+ origin,
+ });
+ await extension.awaitMessage("continue");
+
+ // open a tab from an extension principal
+ tabExt.sendMessage("create");
+ await extension.awaitMessage("done");
+ tabExt.sendMessage("done");
+ await tabExt.awaitMessage("done");
+ await tabExt.unload();
+});
+
+add_task(async function test_webRequest_frames() {
+ let expect = {
+ "redirection.sjs": {
+ status: 302,
+ type: "sub_frame",
+ events: ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", "onBeforeRedirect"],
+ },
+ "dummy_page.html": {
+ type: "sub_frame",
+ status: 404,
+ },
+ "badrobot": {
+ type: "sub_frame",
+ status: 404,
+ events: ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onErrorOccurred"],
+ // When an url's hostname fails to be resolved, an NS_ERROR_NET_ON_RESOLVED/RESOLVING
+ // onError event may be fired right before the NS_ERROR_UNKNOWN_HOST
+ // (See Bug 1516862 for a rationale).
+ optional_events: ["onErrorOccurred"],
+ error: ["NS_ERROR_UNKNOWN_HOST", "NS_ERROR_NET_ON_RESOLVED", "NS_ERROR_NET_ON_RESOLVING"],
+ },
+ };
+ extension.sendMessage("set-expected", {
+ ...expectedBaseProps,
+ expect,
+ origin: location.href
+ });
+ await extension.awaitMessage("continue");
+ addFrame("redirection.sjs");
+ addFrame("https://nonresolvablehostname.invalid/badrobot");
+ await extension.awaitMessage("done");
+});
+
+add_task(async function teardown() {
+ await extension.unload();
+});
+
+add_task(async function test_case_preserving() {
+ const manifest = {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "http://mochi.test/",
+ ],
+ };
+
+ async function background() {
+ // This is testing if header names preserve case,
+ // so the case-sensitive comparison is on purpose.
+ function ua({url, requestHeaders}) {
+ if (url.endsWith("?blind-add")) {
+ requestHeaders.push({name: "user-agent", value: "Blind/Add"});
+ return {requestHeaders};
+ }
+ for (const header of requestHeaders) {
+ if (header.name === "User-Agent") {
+ header.value = "Case/Sensitive";
+ }
+ }
+ return {requestHeaders};
+ }
+
+ await browser.webRequest.onBeforeSendHeaders.addListener(ua, {urls: ["<all_urls>"]}, ["blocking", "requestHeaders"]);
+ browser.test.sendMessage("ready");
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({manifest, background});
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ const response1 = await fetch(SimpleTest.getTestFileURL("return_headers.sjs"));
+ const headers1 = JSON.parse(await response1.text());
+
+ is(headers1["user-agent"], "Case/Sensitive", "User-Agent header matched and changed.");
+
+ const response2 = await fetch(SimpleTest.getTestFileURL("return_headers.sjs?blind-add"));
+ const headers2 = JSON.parse(await response2.text());
+
+ is(headers2["user-agent"], "Blind/Add", "User-Agent header blindly added.");
+
+ await extension.unload();
+});
+
+</script>
+</head>
+<body>
+<div id="test">Sample text</div>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html
new file mode 100644
index 0000000000..cbfc5c17e7
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html
@@ -0,0 +1,59 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for WebRequest errors</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<script type="text/javascript">
+"use strict";
+
+async function test_connection_refused(url, expectedError) {
+ async function background(url, expectedError) {
+ browser.test.log(`background url is ${url}`);
+ browser.webRequest.onErrorOccurred.addListener(details => {
+ if (details.url != url) {
+ return;
+ }
+ browser.test.assertTrue(details.error.startsWith(expectedError), "error correct");
+ browser.test.sendMessage("onErrorOccurred");
+ }, {urls: ["<all_urls>"]});
+
+ let tabId;
+ browser.test.onMessage.addListener(async (msg, expected) => {
+ await browser.tabs.remove(tabId);
+ browser.test.sendMessage("done");
+ });
+
+ let tab = await browser.tabs.create({url});
+ tabId = tab.id;
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: ["webRequest", "tabs", "*://badchain.include-subdomains.pinning.example.com/*"],
+ },
+ background: `(${background})("${url}", "${expectedError}")`,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitMessage("onErrorOccurred");
+ extension.sendMessage("close-tab");
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+}
+
+add_task(function test_bad_cert() {
+ return test_connection_refused("https://badchain.include-subdomains.pinning.example.com/", "Unable to communicate securely with peer");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html
new file mode 100644
index 0000000000..5ccbf761ec
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html
@@ -0,0 +1,226 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <script type="text/javascript" src="head_webrequest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+if (AppConstants.platform === "android") {
+ SimpleTest.requestLongerTimeout(6);
+}
+
+let windowData, testWindow;
+
+add_task(async function setup() {
+ await SpecialPowers.spawnChrome([], async () => {
+ Services.cache2.clear();
+ });
+
+ testWindow = window.open("about:blank", "_blank", "width=100,height=100");
+ await waitForLoad(testWindow);
+
+ // Fetch the windowId and tabId we need to filter with WebRequest.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "tabs",
+ ],
+ },
+ background() {
+ browser.tabs.query({currentWindow: true}).then(tabs => {
+ let tab = tabs.find(tab => tab.active);
+ let {windowId} = tab;
+
+ browser.test.log(`current window ${windowId} tabs: ${JSON.stringify(tabs.map(tab => [tab.id, tab.url]))}`);
+ browser.test.sendMessage("windowData", {windowId, tabId: tab.id});
+ });
+ },
+ });
+ await extension.startup();
+ windowData = await extension.awaitMessage("windowData");
+ info(`window is ${JSON.stringify(windowData)}`);
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_filter_window() {
+ if (AppConstants.MOZ_BUILD_APP !== "browser") {
+ // Android does not support multiple windows.
+ return;
+ }
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.serviceWorkers.testing.enabled", true],
+ ["network.http.rcwn.enabled", false]],
+ });
+
+ let events = {
+ "onBeforeRequest": [{urls: ["<all_urls>"], windowId: windowData.windowId}],
+ "onBeforeSendHeaders": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["requestHeaders"]],
+ "onSendHeaders": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["requestHeaders"]],
+ "onBeforeRedirect": [{urls: ["<all_urls>"], windowId: windowData.windowId}],
+ "onHeadersReceived": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["responseHeaders"]],
+ "onResponseStarted": [{urls: ["<all_urls>"], windowId: windowData.windowId}],
+ "onCompleted": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["responseHeaders"]],
+ "onErrorOccurred": [{urls: ["<all_urls>"], windowId: windowData.windowId}],
+ };
+ let expect = {
+ "file_image_bad.png": {
+ optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders"],
+ type: "main_frame",
+ },
+ };
+
+ if (AppConstants.platform != "android") {
+ expect["favicon.ico"] = {
+ // These events only happen in non-e10s. See bug 1472156.
+ optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders"],
+ type: "image",
+ origin: SimpleTest.getTestFileURL("file_image_bad.png"),
+ };
+ }
+
+ let extension = makeExtension(events);
+ await extension.startup();
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+
+ // We should not get events for a new window load.
+ let newWindow = window.open("file_image_good.png", "_blank", "width=100,height=100");
+ await waitForLoad(newWindow);
+ newWindow.close();
+
+ // We should not get background events.
+ let registration = await navigator.serviceWorker.register("webrequest_worker.js?test0", {scope: "."});
+ await waitForState(registration.installing, "activated");
+
+ // We should get events for the reload.
+ testWindow.location = "file_image_bad.png";
+ await extension.awaitMessage("done");
+
+ testWindow.location = "about:blank";
+ await registration.unregister();
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_filter_tab() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.serviceWorkers.testing.enabled", true]],
+ });
+
+ let img = `file_image_good.png?r=${Math.random()}`;
+
+ let events = {
+ "onBeforeRequest": [{urls: ["<all_urls>"], tabId: windowData.tabId}],
+ "onBeforeSendHeaders": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["requestHeaders"]],
+ "onSendHeaders": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["requestHeaders"]],
+ "onBeforeRedirect": [{urls: ["<all_urls>"], tabId: windowData.tabId}],
+ "onHeadersReceived": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["responseHeaders"]],
+ "onResponseStarted": [{urls: ["<all_urls>"], tabId: windowData.tabId}],
+ "onCompleted": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["responseHeaders"]],
+ "onErrorOccurred": [{urls: ["<all_urls>"], tabId: windowData.tabId}],
+ };
+ let expect = {
+ "file_image_good.png": {
+ // These events only happen in non-e10s. See bug 1472156.
+ optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders"],
+ type: "main_frame",
+ // cached: AppConstants.MOZ_BUILD_APP === "browser",
+ },
+ };
+
+ if (AppConstants.platform != "android") {
+ // A favicon request may be initiated, and complete or be aborted.
+ expect["favicon.ico"] = {
+ optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", "onResponseStarted", "onCompleted", "onErrorOccurred"],
+ type: "image",
+ origin: SimpleTest.getTestFileURL(img),
+ };
+ }
+
+ let extension = makeExtension(events);
+ await extension.startup();
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+
+ if (AppConstants.MOZ_BUILD_APP === "browser") {
+ // We should not get events for a new window load.
+ let newWindow = window.open(img, "_blank", "width=100,height=100");
+ await waitForLoad(newWindow);
+ newWindow.close();
+ }
+
+ // We should not get background events.
+ let registration = await navigator.serviceWorker.register("webrequest_worker.js?test1", {scope: "."});
+ await waitForState(registration.installing, "activated");
+
+ // We should get events for the reload.
+ testWindow.location = img;
+ await extension.awaitMessage("done");
+
+ testWindow.location = "about:blank";
+ await registration.unregister();
+ await extension.unload();
+});
+
+
+add_task(async function test_webRequest_filter_background() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.serviceWorkers.testing.enabled", true]],
+ });
+
+ let events = {
+ "onBeforeRequest": [{urls: ["<all_urls>"], tabId: -1}],
+ "onBeforeSendHeaders": [{urls: ["<all_urls>"], tabId: -1}, ["requestHeaders"]],
+ "onSendHeaders": [{urls: ["<all_urls>"], tabId: -1}, ["requestHeaders"]],
+ "onBeforeRedirect": [{urls: ["<all_urls>"], tabId: -1}],
+ "onHeadersReceived": [{urls: ["<all_urls>"], tabId: -1}, ["responseHeaders"]],
+ "onResponseStarted": [{urls: ["<all_urls>"], tabId: -1}],
+ "onCompleted": [{urls: ["<all_urls>"], tabId: -1}, ["responseHeaders"]],
+ "onErrorOccurred": [{urls: ["<all_urls>"], tabId: -1}],
+ };
+ let expect = {
+ "webrequest_worker.js": {
+ type: "script",
+ },
+ "example.txt": {
+ status: 404,
+ events: ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", "onResponseStarted"],
+ optional_events: ["onCompleted", "onErrorOccurred"],
+ type: "xmlhttprequest",
+ origin: SimpleTest.getTestFileURL("webrequest_worker.js?test2"),
+ },
+ };
+
+ let extension = makeExtension(events);
+ await extension.startup();
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+
+ // We should not get events for a window.
+ testWindow.location = "file_image_bad.png";
+
+ // We should get events for the background page.
+ let registration = await navigator.serviceWorker.register(SimpleTest.getTestFileURL("webrequest_worker.js?test2"), {scope: "."});
+ await waitForState(registration.installing, "activated");
+ await extension.awaitMessage("done");
+ testWindow.location = "about:blank";
+ await registration.unregister();
+
+ await extension.unload();
+});
+
+add_task(async function teardown() {
+ testWindow.close();
+});
+</script>
+</head>
+<body>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html
new file mode 100644
index 0000000000..76a13be1af
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html
@@ -0,0 +1,213 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head_webrequest.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+let extensionData = {
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>", "tabs"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(details => {
+ if (details.url.endsWith("/favicon.ico")) {
+ // We don't care about favicon.ico in this test. It is hard to control
+ // whether the request happens.
+ browser.test.log(`Ignoring favicon request: ${details.url}`);
+ return;
+ }
+ browser.test.sendMessage("onBeforeRequest", details);
+ }, {urls: ["<all_urls>"]}, ["blocking"]);
+
+ let tab;
+ browser.tabs.onCreated.addListener(newTab => {
+ browser.test.sendMessage("tab-created");
+ tab = newTab;
+ });
+
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "close-tab") {
+ browser.tabs.remove(tab.id);
+ browser.test.sendMessage("tab-closed");
+ }
+ });
+ },
+};
+
+let expected = {
+ "file_simple_xhr.html": {
+ type: "main_frame",
+ toplevel: true,
+ },
+ "file_image_good.png": {
+ type: "image",
+ toplevel: true,
+ origin: "file_simple_xhr.html",
+ },
+ "example.txt": {
+ type: "xmlhttprequest",
+ toplevel: true,
+ origin: "file_simple_xhr.html",
+ },
+ // sub frames will have the origin and first ancestor is the
+ // parent document
+ "file_simple_xhr_frame.html": {
+ type: "sub_frame",
+ toplevelParent: true,
+ origin: "file_simple_xhr.html",
+ parent: "file_simple_xhr.html",
+ },
+ // a resource in a sub frame will have origin of the subframe,
+ // but the ancestor chain starts with the parent document
+ "xhr_resource": {
+ type: "xmlhttprequest",
+ origin: "file_simple_xhr_frame.html",
+ parent: "file_simple_xhr.html",
+ },
+ "file_image_bad.png": {
+ type: "image",
+ depth: 2,
+ origin: "file_simple_xhr_frame.html",
+ parent: "file_simple_xhr.html",
+ },
+ "file_simple_xhr_frame2.html": {
+ type: "sub_frame",
+ depth: 2,
+ origin: "file_simple_xhr_frame.html",
+ parent: "file_simple_xhr_frame.html",
+ },
+ "file_image_redirect.png": {
+ type: "image",
+ depth: 2,
+ origin: "file_simple_xhr_frame2.html",
+ parent: "file_simple_xhr_frame.html",
+ },
+ "xhr_resource_2": {
+ type: "xmlhttprequest",
+ depth: 2,
+ origin: "file_simple_xhr_frame2.html",
+ parent: "file_simple_xhr_frame.html",
+ },
+ // This is loaded in a sandbox iframe. originUrl is not available for that,
+ // and requests within a sandboxed iframe will additionally have an empty
+ // url on their immediate parent/ancestor.
+ "file_simple_sandboxed_frame.html": {
+ type: "sub_frame",
+ depth: 3,
+ parent: "file_simple_xhr_frame2.html",
+ },
+ "xhr_sandboxed": {
+ type: "xmlhttprequest",
+ sandboxed: true,
+ depth: 3,
+ parent: "",
+ },
+ "file_image_great.png": {
+ type: "image",
+ sandboxed: true,
+ depth: 3,
+ parent: "",
+ },
+ "file_simple_sandboxed_subframe.html": {
+ type: "sub_frame",
+ depth: 4,
+ parent: "",
+ },
+};
+
+function checkDetails(details) {
+ let url = new URL(details.url);
+ let filename = url.pathname.split("/").pop();
+ ok(filename in expected, `Should be expecting a request for ${filename}`);
+ let expect = expected[filename];
+ is(expect.type, details.type, `${details.type} type matches`);
+ if (details.parentFrameId == -1) {
+ is(details.frameAncestors.length, 0, "no ancestors for main_frame requests");
+ } else if (details.parentFrameId == 0) {
+ is(details.frameAncestors.length, 1, "one ancestors for sub_frame requests");
+ } else {
+ ok(details.frameAncestors.length > 1, "have multiple ancestors for deep subframe requests");
+ is(details.frameAncestors.length, expect.depth, "have multiple ancestors for deep subframe requests");
+ }
+ if (details.parentFrameId > -1) {
+ ok(!expect.origin || details.originUrl.includes(expect.origin), "origin url is correct");
+ is(details.frameAncestors[0].frameId, details.parentFrameId, "first ancestor matches request.parentFrameId");
+ ok(details.frameAncestors[0].url.includes(expect.parent), "ancestor parent page correct");
+ is(details.frameAncestors[details.frameAncestors.length - 1].frameId, 0, "last ancestor is always zero");
+ // All our tests should be somewhere within the frame that we set topframe in the query string. That
+ // frame will always be the last ancestor.
+ ok(details.frameAncestors[details.frameAncestors.length - 1].url.includes("topframe=true"), "last ancestor is always topframe");
+ }
+ if (expect.toplevel) {
+ is(details.frameId, 0, "expect load at top level");
+ is(details.parentFrameId, -1, "expect top level frame to have no parent");
+ } else if (details.type == "sub_frame") {
+ ok(details.frameId > 0, "expect sub_frame to load into a new frame");
+ if (expect.toplevelParent) {
+ is(details.parentFrameId, 0, "expect sub_frame to have top level parent");
+ is(details.frameAncestors.length, 1, "one ancestor for top sub_frame request");
+ } else {
+ ok(details.parentFrameId > 0, "expect sub_frame to have parent");
+ ok(details.frameAncestors.length > 1, "sub_frame has ancestors");
+ }
+ expect.subframeId = details.frameId;
+ expect.parentId = details.parentFrameId;
+ } else if (expect.sandboxed) {
+ is(details.documentUrl, undefined, "null principal documentUrl for sandboxed request");
+ } else {
+ // get the parent frame.
+ let purl = new URL(details.documentUrl);
+ let pfilename = purl.pathname.split("/").pop();
+ let parent = expected[pfilename];
+ is(details.frameId, parent.subframeId, "expect load in subframe");
+ is(details.parentFrameId, parent.parentId, "expect subframe parent");
+ }
+ return filename;
+}
+
+add_task(async function test_webRequest_main_frame() {
+ // Clear the image cache, since it gets in the way otherwise.
+ let imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools);
+ let cache = imgTools.getImgCacheForDocument(document);
+ cache.clearCache(false);
+ await SpecialPowers.spawnChrome([], async () => {
+ Services.cache2.clear();
+ });
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let a = addLink(`file_simple_xhr.html?topframe=true&nocache=${Math.random()}`);
+ a.click();
+
+ let remaining = new Set(Object.keys(expected));
+ let totalExpectedCount = remaining.size;
+ for (let i = 0; i < totalExpectedCount; i++) {
+ info(`Waiting for request ${i + 1} out of ${totalExpectedCount}`);
+ info(`Expecting one of: ${Array.from(remaining)}`);
+ let details = await extension.awaitMessage("onBeforeRequest");
+ info(`Checking details for request ${i}: ${JSON.stringify(details)}`);
+ let filename = checkDetails(details);
+ ok(remaining.delete(filename), `Got only one request for ${filename}`);
+ }
+
+ await extension.awaitMessage("tab-created");
+ extension.sendMessage("close-tab");
+ await extension.awaitMessage("tab-closed");
+
+ await extension.unload();
+});
+</script>
+</head>
+<body>
+<div id="test">Sample text</div>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_getSecurityInfo.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_getSecurityInfo.html
new file mode 100644
index 0000000000..5628109483
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_getSecurityInfo.html
@@ -0,0 +1,98 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>browser.webRequest.getSecurityInfo()</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script>
+"use strict";
+
+add_task(async function test_getSecurityInfo() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>"
+ ],
+ },
+ async background() {
+ const url = "https://example.org/tests/toolkit/components/extensions/test/mochitest/file_sample.html";
+
+ let tab;
+ browser.webRequest.onHeadersReceived.addListener(async details => {
+ const securityInfo = await browser.webRequest.getSecurityInfo(
+ details.requestId,
+ {}
+ );
+
+ // Some properties have dynamic values so let's take them out of the
+ // `securityInfo` object before asserting all the other props with deep
+ // equality.
+ const {
+ cipherSuite,
+ secretKeyLength,
+ keaGroupName,
+ signatureSchemeName,
+ protocolVersion,
+ certificates,
+ ...otherProps
+ } = securityInfo;
+
+ browser.test.assertTrue(cipherSuite.length, "expected cipher suite");
+ browser.test.assertTrue(
+ Number.isInteger(secretKeyLength),
+ "expected secret key length"
+ );
+ browser.test.assertTrue(
+ keaGroupName.length,
+ "expected kea group name"
+ );
+ browser.test.assertTrue(
+ signatureSchemeName.length,
+ "expected signature scheme name"
+ );
+ browser.test.assertTrue(
+ protocolVersion.length,
+ "expected protocol version"
+ );
+ browser.test.assertTrue(
+ Array.isArray(certificates),
+ "expected an array of certificates"
+ );
+
+ browser.test.assertDeepEq({
+ state: "secure",
+ isExtendedValidation: false,
+ certificateTransparencyStatus: "not_applicable",
+ hsts: false,
+ hpkp: false,
+ usedEch: false,
+ usedDelegatedCredentials: false,
+ usedOcsp: false,
+ usedPrivateDns: false,
+ }, otherProps, "expected security info");
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("success");
+ }, { urls: [url] } , ["blocking"]);
+
+ tab = await browser.tabs.create({ url });
+ },
+ });
+ await extension.startup();
+
+ await extension.awaitFinish("success");
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html
new file mode 100644
index 0000000000..e66b5c471a
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html
@@ -0,0 +1,252 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <script type="text/javascript" src="head_webrequest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+function getExtension() {
+ async function background() {
+ let expect;
+ let urls = ["*://*.example.org/tests/*"];
+ browser.webRequest.onBeforeRequest.addListener(details => {
+ browser.test.assertEq(expect.shift(), "onBeforeRequest");
+ }, {urls}, ["blocking"]);
+ browser.webRequest.onBeforeSendHeaders.addListener(details => {
+ browser.test.assertEq(expect.shift(), "onBeforeSendHeaders");
+ }, {urls}, ["blocking", "requestHeaders"]);
+ browser.webRequest.onSendHeaders.addListener(details => {
+ browser.test.assertEq(expect.shift(), "onSendHeaders");
+ }, {urls}, ["requestHeaders"]);
+
+ async function testSecurityInfo(securityInfo, options) {
+ if (options.certificateChain) {
+ // Some of the tests here only produce a single cert in the chain.
+ browser.test.assertTrue(securityInfo.certificates.length >= 1, "have certificate chain");
+ } else {
+ browser.test.assertTrue(securityInfo.certificates.length == 1, "no certificate chain");
+ }
+ let cert = securityInfo.certificates[0];
+ let now = Date.now();
+ browser.test.assertTrue(Number.isInteger(cert.validity.start), "cert start is integer");
+ browser.test.assertTrue(Number.isInteger(cert.validity.end), "cert end is integer");
+ browser.test.assertTrue(cert.validity.start < now, "cert start validity is correct");
+ browser.test.assertTrue(now < cert.validity.end, "cert end validity is correct");
+ if (options.rawDER) {
+ for (let cert of securityInfo.certificates) {
+ browser.test.assertTrue(!!cert.rawDER.length, "have rawDER");
+ }
+ }
+ }
+
+ function stripQuery(url) {
+ // In this whole test we are not interested in the query part of the URL.
+ // Most tests include a cache buster (bustcache) param in the URL.
+ return url.split("?")[0];
+ }
+
+ browser.webRequest.onHeadersReceived.addListener(async (details) => {
+ browser.test.assertEq(expect.shift(), "onHeadersReceived");
+
+ // We expect all requests to have been upgraded at this point.
+ browser.test.assertTrue(details.url.startsWith("https"), "connection is https");
+ let securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, {});
+ browser.test.assertTrue(securityInfo && securityInfo.state == "secure",
+ "security info reflects https");
+ await testSecurityInfo(securityInfo, {});
+ securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, {certificateChain: true});
+ await testSecurityInfo(securityInfo, {certificateChain: true});
+ securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, {rawDER: true});
+ await testSecurityInfo(securityInfo, {rawDER: true});
+ securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, {certificateChain: true, rawDER: true});
+ await testSecurityInfo(securityInfo, {certificateChain: true, rawDER: true});
+
+ browser.test.sendMessage("hsts", securityInfo.hsts);
+ let headers = details.responseHeaders || [];
+ for (let header of headers) {
+ if (header.name.toLowerCase() === "strict-transport-security") {
+ return;
+ }
+ }
+ if (details.url.includes("addHsts")) {
+ headers.push({
+ name: "Strict-Transport-Security",
+ value: "max-age=31536000000",
+ });
+ }
+ return {responseHeaders: headers};
+ }, {urls}, ["blocking", "responseHeaders"]);
+ browser.webRequest.onBeforeRedirect.addListener(details => {
+ browser.test.assertEq(expect.shift(), "onBeforeRedirect");
+ }, {urls});
+ browser.webRequest.onResponseStarted.addListener(details => {
+ browser.test.assertEq(expect.shift(), "onResponseStarted");
+ }, {urls});
+ browser.webRequest.onCompleted.addListener(details => {
+ browser.test.assertEq(expect.shift(), "onCompleted");
+ browser.test.sendMessage("onCompleted", stripQuery(details.url));
+ }, {urls});
+ browser.webRequest.onErrorOccurred.addListener(details => {
+ browser.test.notifyFail(`onErrorOccurred ${JSON.stringify(details)}`);
+ }, {urls});
+
+ async function onUpdated(tabId, tabInfo, tab) {
+ if (tabInfo.status !== "complete" || tab.url === "about:blank") {
+ return;
+ }
+ browser.tabs.remove(tabId);
+ browser.tabs.onUpdated.removeListener(onUpdated);
+ browser.test.sendMessage("tabs-done", stripQuery(tab.url));
+ }
+ browser.test.onMessage.addListener((url, expected) => {
+ expect = expected;
+ browser.tabs.onUpdated.addListener(onUpdated);
+ browser.tabs.create({url});
+ });
+ }
+
+ let manifest = {
+ "permissions": [
+ "tabs",
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ ],
+ };
+ return ExtensionTestUtils.loadExtension({
+ manifest,
+ background,
+ });
+}
+
+add_setup(async () => {
+ // In bug 1605515, we repeatedly saw a missing onHeadersReceived event,
+ // possibly related to bug 1595610. As a workaround, clear the cache.
+ await SpecialPowers.spawnChrome([], async () => {
+ Services.cache2.clear();
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_HSTS, resolve);
+ });
+ });
+});
+
+// This test makes a request against a server that redirects with a 302.
+add_task(async function test_hsts_request() {
+ const testPath = "example.org/tests/toolkit/components/extensions/test/mochitest";
+
+ let extension = getExtension();
+ await extension.startup();
+
+ // simple redirect
+ let sample = "https://example.org/tests/toolkit/components/extensions/test/mochitest/file_sample.html";
+ extension.sendMessage(
+ `https://${testPath}/redirect_auto.sjs?redirect_uri=${sample}?bustcache1=${Math.random()}`,
+ ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders",
+ "onHeadersReceived", "onBeforeRedirect", "onBeforeRequest",
+ "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived",
+ "onResponseStarted", "onCompleted"]);
+ is(await extension.awaitMessage("hsts"), false, "First request to this host, not receiving a hsts header");
+ is(await extension.awaitMessage("hsts"), false, "second (redirected) reqiest to the same host, still no knowledge about the hosts hsts preference");
+ // Note: stripQuery strips query string added by redirect_auto.
+ is(await extension.awaitMessage("tabs-done"), sample, "redirection ok");
+ is(await extension.awaitMessage("onCompleted"), sample, "redirection ok");
+
+ // priming hsts
+ extension.sendMessage(
+ `https://${testPath}/hsts.sjs`,
+ ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders",
+ "onHeadersReceived", "onResponseStarted", "onCompleted"]);
+ is(await extension.awaitMessage("hsts"), false, "First request to this host, receiving hsts header and saving the hosts STS preference for the next request");
+ is(await extension.awaitMessage("tabs-done"),
+ "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs",
+ "hsts primed");
+ is(await extension.awaitMessage("onCompleted"),
+ "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs");
+
+ // test upgrade
+ extension.sendMessage(
+ `http://${testPath}/hsts.sjs`,
+ ["onBeforeRequest", "onBeforeRedirect", "onBeforeRequest",
+ "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived",
+ "onResponseStarted", "onCompleted"]);
+ is(await extension.awaitMessage("hsts"), true, "second (redirected) reqiest to the same host, we know about the hsts status of the host this time");
+ is(await extension.awaitMessage("tabs-done"),
+ "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs",
+ "hsts upgraded");
+ is(await extension.awaitMessage("onCompleted"),
+ "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs");
+
+ await extension.unload();
+});
+
+// This test makes a priming request and adds the STS header, then tests the upgrade.
+add_task(async function test_hsts_header() {
+ const testPath = "test1.example.org/tests/toolkit/components/extensions/test/mochitest";
+
+ let extension = getExtension();
+ await extension.startup();
+
+ // priming hsts, this time there is no STS header, onHeadersReceived adds it.
+ let completed = extension.awaitMessage("onCompleted");
+ let tabdone = extension.awaitMessage("tabs-done");
+ extension.sendMessage(
+ `https://${testPath}/file_sample.html?bustcache2=${Math.random()}&addHsts=true`,
+ ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders",
+ "onHeadersReceived", "onResponseStarted", "onCompleted"]);
+ is(await extension.awaitMessage("hsts"), false, "First reqeuest to this host, we don't know about the hosts STS setting yet");
+ is(await tabdone, `https://${testPath}/file_sample.html`, "priming request done");
+ is(await completed, `https://${testPath}/file_sample.html`, "priming request done");
+
+ // test upgrade from http to https due to onHeadersReceived adding STS header
+ completed = extension.awaitMessage("onCompleted");
+ tabdone = extension.awaitMessage("tabs-done");
+ extension.sendMessage(
+ `http://${testPath}/file_sample.html?bustcache3=${Math.random()}`,
+ ["onBeforeRequest", "onBeforeRedirect", "onBeforeRequest",
+ "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived",
+ "onResponseStarted", "onCompleted"]);
+ is(await extension.awaitMessage("hsts"), true, "We have received an hsts header last request via oneadersReceived");
+ is(await tabdone, `https://${testPath}/file_sample.html`, "hsts upgraded");
+ is(await completed, `https://${testPath}/file_sample.html`, "request upgraded");
+
+ await extension.unload();
+});
+
+add_task(async function test_nonBlocking_securityInfo() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ "permissions": [
+ "webRequest",
+ "<all_urls>",
+ ],
+ },
+ async background() {
+ let tab;
+ browser.webRequest.onHeadersReceived.addListener(async (details) => {
+ let securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, {});
+ browser.test.assertTrue(!securityInfo, "securityInfo undefined on http request");
+ browser.tabs.remove(tab.id);
+ browser.test.notifyPass("success");
+ }, {urls: ["<all_urls>"], types: ["main_frame"]});
+ tab = await browser.tabs.create({
+ url: `https://example.org/tests/toolkit/components/extensions/test/mochitest/file_sample.html?bustcache4=${Math.random()}`,
+ });
+ },
+ });
+ await extension.startup();
+
+ await extension.awaitFinish("success");
+ await extension.unload();
+});
+</script>
+</head>
+<body>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html
new file mode 100644
index 0000000000..87dbbd6598
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html
@@ -0,0 +1,75 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Bug 1450965: Skip Cors Check for Early WebExtention Redirects </title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+/* Description of the test:
+ * We try to Check if a WebExtention can redirect a request and bypass CORS
+ * We're redirecting a fetch request in onBeforeRequest
+ * which should not be blocked, even though we do not have
+ * the CORS information yet.
+ */
+
+const WIN_URL =
+ "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html";
+
+
+add_task(async function test_webRequest_redirect_cors_bypass() {
+ // disable third-party storage isolation so the test works as expected
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.partition.always_partition_third_party_non_cookie_storage", false]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ ],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener((details) => {
+ if (details.url.includes("file_cors_blocked.txt")) {
+ // File_cors_blocked does not need to exist, because we're redirecting anyway.
+ const testPath = "example.org/tests/toolkit/components/extensions/test/mochitest";
+ let redirectUrl = `https://${testPath}/file_sample.txt`;
+
+ // If the WebExtion cant bypass CORS, the fetch will throw a CORS-Exception
+ // because we do not have the CORS header yet for 'file-cors-blocked.txt'
+ return {redirectUrl};
+ }
+ }, {urls: ["<all_urls>"]}, ["blocking"]);
+ },
+
+ });
+
+ await extension.startup();
+ let win = window.open(WIN_URL);
+ // Creating a message channel to the new tab.
+ const channel = new BroadcastChannel("test_bus");
+ await new Promise((resolve, reject) => {
+ channel.onmessage = async function(fetch_result) {
+ // Fetch result data will either be the text content of file_sample.txt -> 'Sample'
+ // or a network-Error.
+ // In case it's 'Sample' the redirect did happen correctly.
+ ok(fetch_result.data == "Sample", "Cors was Bypassed");
+ win.close();
+ await extension.unload();
+ resolve();
+ };
+ });
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html
new file mode 100644
index 0000000000..5d58549c46
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html
@@ -0,0 +1,83 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Bug 1434357: Allow Web Request API to redirect to data: URI</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+/* Description of the test:
+ * We load a *.js file which gets redirected to a data: URI.
+ * Since there is no good way to communicate loaded data: URI scripts
+ * we use updating a divContainer as a detour to verify the data: URI
+ * script has loaded.
+ */
+
+const WIN_URL =
+ "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html";
+
+add_task(async function test_webRequest_redirect_data_uri() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "*://mochi.test/tests/*",
+ ],
+ content_scripts: [{
+ matches: ["*://mochi.test/tests/*/file_redirect_data_uri.html"],
+ run_at: "document_end",
+ js: ["content_script.js"],
+ "all_frames": true,
+ }],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener((details) => {
+ if (details.url.includes("dummy_non_existend_file.js")) {
+ let redirectUrl =
+ "data:text/javascript,document.getElementById('testdiv').textContent='loaded'";
+ return {redirectUrl};
+ }
+ }, {urls: ["*://mochi.test/tests/*"]}, ["blocking"]);
+ },
+
+ files: {
+ "content_script.js": function() {
+ let scriptEl = document.createElement("script");
+ // please note that dummy_non_existend_file.js file does not really need
+ // to exist because we redirect the load within onBeforeRequest().
+ scriptEl.src = "dummy_non_existend_file.js";
+ document.body.appendChild(scriptEl);
+
+ scriptEl.onload = function() {
+ let divContent = document.getElementById("testdiv").textContent;
+ browser.test.assertEq(divContent, "loaded",
+ "redirect to data: URI allowed");
+ browser.test.sendMessage("finished");
+ };
+ scriptEl.onerror = function() {
+ browser.test.fail("script load failure");
+ browser.test.sendMessage("finished");
+ };
+ },
+ },
+ });
+
+ await extension.startup();
+ let win = window.open(WIN_URL);
+ await extension.awaitMessage("finished");
+ win.close();
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html
new file mode 100644
index 0000000000..f086d29d02
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html
@@ -0,0 +1,139 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_webRequest_upgrade() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "*://mochi.test/tests/*",
+ ],
+ },
+ background() {
+ browser.webRequest.onSendHeaders.addListener((details) => {
+ // At this point, the request should have been upgraded.
+ browser.test.assertTrue(details.url.startsWith("https:"), "request is upgraded");
+ browser.test.assertTrue(details.url.includes("file_sample"), "redirect after upgrade worked");
+ // Note: although not significant for the test assertions, note that
+ // the requested file won't load - https://mochi.test:8888/ does not
+ // resolve to anything on the test server.
+ browser.test.sendMessage("finished");
+ }, {urls: ["*://mochi.test/tests/*"]});
+
+ browser.webRequest.onBeforeRequest.addListener((details) => {
+ browser.test.log(`onBeforeRequest ${details.requestId} ${details.url}`);
+ let url = new URL(details.url);
+ if (url.protocol == "http:") {
+ return {upgradeToSecure: true};
+ }
+ // After the channel is initially upgraded, we get another onBeforeRequest
+ // call. Here we can redirect again to a new url.
+ if (details.url.includes("file_mixed.html")) {
+ let redirectUrl = new URL("file_sample.html", details.url).href;
+ return {redirectUrl};
+ }
+ }, {urls: ["*://mochi.test/tests/*"]}, ["blocking"]);
+ },
+ });
+
+ await extension.startup();
+ let win = window.open("http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_mixed.html");
+ await extension.awaitMessage("finished");
+ win.close();
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_redirect_wins() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "*://mochi.test/tests/*",
+ ],
+ },
+ background() {
+ browser.webRequest.onSendHeaders.addListener((details) => {
+ // At this point, the request should have been redirected instead of upgraded.
+ browser.test.assertTrue(details.url.includes("file_sample"), "request was redirected");
+ browser.test.sendMessage("finished");
+ }, {urls: ["*://mochi.test/tests/*"]});
+
+ browser.webRequest.onBeforeRequest.addListener((details) => {
+ if (details.url.includes("file_mixed.html")) {
+ let redirectUrl = new URL("file_sample.html", details.url).href;
+ return {upgradeToSecure: true, redirectUrl};
+ }
+ }, {urls: ["*://mochi.test/tests/*"]}, ["blocking"]);
+ },
+ });
+
+ await extension.startup();
+ let win = window.open("http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_mixed.html");
+ await extension.awaitMessage("finished");
+ win.close();
+ await extension.unload();
+});
+
+// Test that there is no infinite redirect loop when upgradeToSecure is used on
+// https. This test checks that the redirect chain is: http -> https -> done.
+add_task(async function upgradeToSecure_for_https_is_noop() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "*://example.com/tests/*",
+ ],
+ },
+ background() {
+ let count = 0;
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.log(`onBeforeRequest ${details.requestId} ${details.url}`);
+ ++count;
+ if (details.url.startsWith("http:")) {
+ browser.test.assertEq(1, count, "Initial request is http:");
+ } else {
+ browser.test.assertEq(2, count, "Second request is https:");
+ }
+ return {upgradeToSecure: true};
+ },
+ { urls: ["*://example.com/tests/*file_sample.html"] },
+ ["blocking"]
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.log(`onCompleted ${details.requestId} ${details.url}`);
+ browser.test.assertTrue(details.url.startsWith("https"), "is https");
+ browser.test.assertEq(2, count, "Seen two requests (http + https)");
+ browser.test.sendMessage("finished");
+ },
+ { urls: ["*://example.com/tests/*file_sample.html"] },
+ );
+ },
+ });
+
+ await extension.startup();
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let win = window.open("http://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("finished");
+ win.close();
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html
new file mode 100644
index 0000000000..30ecb0aa78
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html
@@ -0,0 +1,265 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<form method="post"
+ action="file_WebRequest_page3.html?trigger=form"
+ target="_blank"
+ enctype="multipart/form-data"
+ >
+<input type="text" name="&quot;special&quot; &#x0D;&#x0A; ch�rs" value="sp�cial">
+<input type="file" name="testFile">
+<input type="file" name="emptyFile">
+<input type="text" name="textInput1" value="value1">
+</form>
+
+<form method="post"
+ action="file_WebRequest_page3.html?trigger=form"
+ target="_blank"
+ enctype="multipart/form-data"
+ >
+<input type="text" name="textInput2" value="value2">
+<input type="file" name="testFile">
+<input type="file" name="emptyFile">
+</form>
+
+</form>
+<form method="post"
+ action="file_WebRequest_page3.html?trigger=form"
+ target="_blank"
+ >
+<input type="text" name="textInput" value="value1">
+<input type="text" name="textInput" value="value2">
+</form>
+<script>
+"use strict";
+
+let files, testFile, blob, file, uploads;
+add_task(async function test_setup() {
+ files = await new Promise(resolve => {
+ SpecialPowers.createFiles([{name: "testFile.pdf", data: "Not really a PDF file :)", "type": "application/x-pdf"}], (result) => {
+ resolve(result);
+ });
+ });
+ testFile = files[0];
+ blob = {
+ name: "blobAsFile",
+ content: new Blob(["A blob sent as a file"], {type: "text/csv"}),
+ fileName: "blobAsFile.csv",
+ };
+ file = {
+ name: "testFile",
+ fileName: testFile.name,
+ };
+ uploads = {
+ [blob.name]: blob,
+ [file.name]: file,
+ "emptyFile": {fileName: ""}
+ };
+});
+
+function background() {
+ const FILTERS = {urls: ["<all_urls>"]};
+
+ function onUpload(details) {
+ let url = new URL(details.url);
+ let upload = url.searchParams.get("upload");
+ if (!upload) {
+ return;
+ }
+
+ let requestBody = details.requestBody;
+ browser.test.log(`onBeforeRequest upload: ${details.url} ${JSON.stringify(details.requestBody)}`);
+ browser.test.assertTrue(!!requestBody, `Intercepted upload ${details.url} #${details.requestId} ${upload} have a requestBody`);
+ if (!requestBody) {
+ return;
+ }
+ let byteLength = parseInt(upload, 10);
+ if (byteLength) {
+ browser.test.assertTrue(!!requestBody.raw, `Binary upload ${details.url} #${details.requestId} ${upload} have a raw attribute`);
+ browser.test.assertEq(byteLength, requestBody.raw && requestBody.raw.map(r => r.bytes ? r.bytes.byteLength : 0).reduce((a, b) => a + b), `Binary upload size matches`);
+ return;
+ }
+ if ("raw" in requestBody) {
+ browser.test.assertEq(upload, JSON.stringify(requestBody.raw).replace(/(\bfile: ")[^"]+/, "$1<file>"), `Upload ${details.url} #${details.requestId} matches raw data`);
+ } else {
+ browser.test.assertEq(upload, JSON.stringify(requestBody.formData), `Upload ${details.url} #${details.requestId} matches form data.`);
+ }
+ }
+
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.log(`onCompleted ${details.requestId} ${details.url}`);
+ // See bug 1471387
+ if (details.url.endsWith("/favicon.ico") || details.originUrl == "about:newtab") {
+ return;
+ }
+
+ browser.test.sendMessage("done");
+ },
+ FILTERS);
+
+ let onBeforeRequest = details => {
+ browser.test.log(`${name} ${details.requestId} ${details.url}`);
+ // See bug 1471387
+ if (details.url.endsWith("/favicon.ico") || details.originUrl == "about:newtab") {
+ return;
+ }
+
+ onUpload(details);
+ };
+
+ browser.webRequest.onBeforeRequest.addListener(
+ onBeforeRequest, FILTERS, ["requestBody"]);
+
+ let tab;
+ browser.tabs.onCreated.addListener(newTab => {
+ tab = newTab;
+ });
+
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "close-tab") {
+ browser.tabs.remove(tab.id);
+ browser.test.sendMessage("tab-closed");
+ }
+ });
+}
+
+add_task(async function test_xhr_forms() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "tabs",
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ ],
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ async function doneAndTabClosed() {
+ await extension.awaitMessage("done");
+ let closed = extension.awaitMessage("tab-closed");
+ extension.sendMessage("close-tab");
+ await closed;
+ }
+
+ for (let form of document.forms) {
+ if (file.name in form.elements) {
+ SpecialPowers.wrap(form.elements[file.name]).mozSetFileArray(files);
+ }
+ let action = new URL(form.action);
+ let formData = new FormData(form);
+ let webRequestFD = {};
+
+ let updateActionURL = () => {
+ for (let name of formData.keys()) {
+ webRequestFD[name] = name in uploads ? [uploads[name].fileName] : formData.getAll(name);
+ }
+ action.searchParams.set("upload", JSON.stringify(webRequestFD));
+ action.searchParams.set("enctype", form.enctype);
+ };
+
+ updateActionURL();
+
+ form.action = action;
+ form.submit();
+ await doneAndTabClosed();
+
+ if (form.enctype !== "multipart/form-data") {
+ continue;
+ }
+
+ let post = (data) => {
+ let xhr = new XMLHttpRequest();
+ action.searchParams.set("xhr", "1");
+ xhr.open("POST", action.href);
+ xhr.send(data);
+ action.searchParams.delete("xhr");
+ return doneAndTabClosed();
+ };
+
+ formData.append(blob.name, blob.content, blob.fileName);
+ formData.append("formDataField", "some value");
+ updateActionURL();
+ await post(formData);
+
+ action.searchParams.set("upload", JSON.stringify([{file: "<file>"}]));
+ await post(testFile);
+
+ action.searchParams.set("upload", `${blob.content.size} bytes`);
+ await post(blob.content);
+
+ let byteLength = 16;
+ action.searchParams.set("upload", `${byteLength} bytes`);
+ await post(new ArrayBuffer(byteLength));
+ }
+
+ // Testing the decoding of percent escapes even in cases where the
+ // multipart/form-data serializer won't emit them.
+ {
+ let boundary = "-".repeat(27);
+ for (let i = 0; i < 3; i++) {
+ const randomNumber = Math.floor(Math.random() * (2 ** 32));
+ boundary += String(randomNumber);
+ }
+
+ const formPayload = [
+ `--${boundary}`,
+ 'Content-Disposition: form-data; name="percent escapes other than%20quotes and newlines"',
+ "",
+ "",
+ `--${boundary}`,
+ 'Content-Disposition: form-data; name="valid UTF-8: %F0%9F%92%A9"',
+ "",
+ "",
+ `--${boundary}`,
+ 'Content-Disposition: form-data; name="broken UTF-8: %F0%9F %92%A9"',
+ "",
+ "",
+ `--${boundary}`,
+ 'Content-Disposition: form-data; name="percent escapes aren\'t decoded in filenames"; filename="%0D%0A%22"',
+ "Content-Type: application/octet-stream",
+ "",
+ "",
+ `--${boundary}--`,
+ ""
+ ].join("\r\n");
+
+ const action = new URL("file_WebRequest_page3.html?trigger=form", document.location.href);
+ action.searchParams.set("xhr", "1");
+ action.searchParams.set("upload", JSON.stringify({
+ "percent escapes other than quotes and newlines": [""],
+ "valid UTF-8: 💩": [""],
+ "broken UTF-8: � ��": [""],
+ "percent escapes aren't decoded in filenames": ["%0D%0A%22"]
+ }));
+ action.searchParams.set("enctype", "multipart/form-data");
+
+ await fetch(
+ action.href,
+ {
+ method: "POST",
+ headers: {"Content-Type": `multipart/form-data; boundary=${boundary}`},
+ body: formPayload
+ },
+ );
+ await doneAndTabClosed();
+ }
+
+ await extension.unload();
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_worker.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_worker.html
new file mode 100644
index 0000000000..9fc3e00f01
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_worker.html
@@ -0,0 +1,192 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head_webrequest.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+let tabId;
+
+let extensionData = {
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>", "tabs"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(details => {
+ if (details.url.endsWith("/favicon.ico")) {
+ // We don't care about favicon.ico in this test. It is hard to control
+ // whether the request happens.
+ browser.test.log(`Ignoring favicon request: ${details.url}`);
+ return;
+ }
+ browser.test.sendMessage("onBeforeRequest", details);
+ }, {urls: ["<all_urls>"]}, ["blocking"]);
+
+ let tab;
+ browser.tabs.onCreated.addListener(newTab => {
+ tab = newTab;
+ browser.test.sendMessage("tab-created", tab.id);
+ });
+
+ browser.test.onMessage.addListener(async (msg) => {
+ if (msg === "close-tab") {
+ await browser.tabs.remove(tab.id);
+ browser.test.sendMessage("tab-closed");
+ }
+ });
+ },
+};
+
+let expected = {
+ "file_simple_webrequest_worker.html?topframe=true": {
+ type: "main_frame",
+ toplevel: true,
+ origin: "test_ext_webrequest_worker.html",
+ tabId: true,
+ parentFrameId: -1,
+ },
+ "file_simple_iframe_worker.html": {
+ type: "sub_frame",
+ toplevel: false,
+ origin: "file_simple_webrequest_worker.html?topframe=true",
+ tabId: true,
+ parentFrameId: 0,
+ },
+ "file_simple_toplevel.txt": {
+ type: "xmlhttprequest",
+ toplevel: true,
+ origin: "file_simple_webrequest_worker.html?topframe=true",
+ tabId: true,
+ parentFrameId: -1,
+ },
+ "file_simple_iframe.txt": {
+ type: "xmlhttprequest",
+ toplevel: false,
+ origin: "file_simple_iframe_worker.html",
+ tabId: true,
+ parentFrameId: 0,
+ },
+ "file_simple_worker.txt": {
+ type: "xmlhttprequest",
+ toplevel: true,
+ origin: "file_simple_worker.js",
+ tabId: true,
+ parentFrameId: -1,
+ },
+ "file_simple_iframe_worker.txt": {
+ type: "xmlhttprequest",
+ toplevel: false,
+ origin: "file_simple_worker.js?iniframe=true",
+ tabId: true,
+ parentFrameId: 0,
+ },
+ "file_simple_sharedworker.txt": {
+ type: "xmlhttprequest",
+ toplevel: undefined,
+ origin: "file_simple_sharedworker.js",
+ tabId: false,
+ parentFrameId: -1,
+ },
+ "file_simple_iframe_sharedworker.txt": {
+ type: "xmlhttprequest",
+ toplevel: undefined,
+ origin: "file_simple_sharedworker.js?iniframe=true",
+ tabId: false,
+ parentFrameId: -1,
+ },
+ "file_simple_worker.js": {
+ type: "script",
+ toplevel: true,
+ origin: "file_simple_webrequest_worker.html?topframe=true",
+ tabId: true,
+ parentFrameId: -1,
+ },
+ "file_simple_sharedworker.js": {
+ type: "script",
+ toplevel: undefined,
+ origin: "file_simple_webrequest_worker.html?topframe=true",
+ tabId: false,
+ parentFrameId: -1,
+ },
+ "file_simple_worker.js?iniframe=true": {
+ type: "script",
+ toplevel: false,
+ origin: "file_simple_iframe_worker.html",
+ tabId: true,
+ parentFrameId: 0,
+ },
+ "file_simple_sharedworker.js?iniframe=true": {
+ type: "script",
+ toplevel: undefined,
+ origin: "file_simple_iframe_worker.html",
+ tabId: false,
+ parentFrameId: -1,
+ },
+
+
+};
+
+function checkDetails(details) {
+ let filename = details.url.split("/").pop();
+ ok(filename in expected, `Should be expecting a request for ${filename}`);
+ let expect = expected[filename];
+ is(expect.type, details.type, `${details.type} type matches`);
+ const originUrlSuffix = details.originUrl?.split("/").pop();
+ ok(expect.origin === originUrlSuffix || originUrlSuffix.startsWith(expect.origin), `origin url is correct`);
+ is(details.parentFrameId, expect.parentFrameId, "parentFrameId matches");
+ is(expect.tabId ? tabId : -1, details.tabId, "tabId matches");
+ // TODO: When expect.toplevel is "undefined", the details.frameId is supposed
+ // to be -1.
+ // details in https://phabricator.services.mozilla.com/D182705#inline-1030548.
+ if (expect.toplevel === undefined || expect.toplevel) {
+ is(details.frameId, 0, "expect zero frameId");
+ } else {
+ ok(details.frameId > 0, "expect non-zero frameId");
+ }
+ return filename;
+}
+
+add_task(async function test_webRequest_worker() {
+ await SpecialPowers.spawnChrome([], async () => {
+ Services.cache2.clear();
+ });
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let a = addLink(`file_simple_webrequest_worker.html?topframe=true`);
+ a.click();
+ tabId = await extension.awaitMessage("tab-created");
+ info(`Get created tab(${tabId}`);
+
+ let remaining = new Set(Object.keys(expected));
+ let totalExpectedCount = remaining.size;
+ let currentExpectedCount = 0;
+ while (remaining.size !== 0) {
+ info(`Waiting for request ${currentExpectedCount + 1} out of ${totalExpectedCount}`);
+ info(`Expecting one of: ${Array.from(remaining)}`);
+ let details = await extension.awaitMessage("onBeforeRequest");
+ info(`Checking details for request: ${JSON.stringify(details)}`);
+ let filename = checkDetails(details);
+ ok(remaining.delete(filename), `Got only one request for ${filename}`);
+ currentExpectedCount = currentExpectedCount + 1;
+ }
+
+ extension.sendMessage("close-tab");
+ await extension.awaitMessage("tab-closed");
+ await extension.unload();
+});
+
+</script>
+</head>
+<body>
+<div id="test">Sample text</div>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html b/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html
new file mode 100644
index 0000000000..53b19d0ead
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html
@@ -0,0 +1,104 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+/* eslint-disable mozilla/balanced-listeners */
+
+add_task(async function test_postMessage() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ "all_frames": true,
+ },
+ ],
+
+ web_accessible_resources: ["iframe.html"],
+ },
+
+ background() {
+ browser.test.sendMessage("iframe-url", browser.runtime.getURL("iframe.html"));
+ },
+
+ files: {
+ "content_script.js": function() {
+ window.addEventListener("message", event => {
+ if (event.data == "ping") {
+ event.source.postMessage({pong: location.href},
+ event.origin);
+ }
+ });
+ },
+
+ "iframe.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="content_script.js"><\/script>
+ </head>
+ </html>`,
+ },
+ };
+
+ let createIframe = url => {
+ let iframe = document.createElement("iframe");
+ return new Promise(resolve => {
+ iframe.src = url;
+ iframe.onload = resolve;
+ document.body.appendChild(iframe);
+ }).then(() => {
+ return iframe;
+ });
+ };
+
+ let awaitMessage = () => {
+ return new Promise(resolve => {
+ let listener = event => {
+ if (event.data.pong) {
+ window.removeEventListener("message", listener);
+ resolve(event.data);
+ }
+ };
+ window.addEventListener("message", listener);
+ });
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let iframeURL = await extension.awaitMessage("iframe-url");
+ let testURL = SimpleTest.getTestFileURL("file_sample.html");
+
+ for (let url of [iframeURL, testURL]) {
+ info(`Testing URL ${url}`);
+
+ let iframe = await createIframe(url);
+
+ iframe.contentWindow.postMessage(
+ "ping", url);
+
+ let pong = await awaitMessage();
+ is(pong.pong, url, "Got expected pong");
+
+ iframe.remove();
+ }
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_startup_canary.html b/toolkit/components/extensions/test/mochitest/test_startup_canary.html
new file mode 100644
index 0000000000..1f705940c2
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_startup_canary.html
@@ -0,0 +1,76 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Check StartupCache</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// The startup canary file is removed sometime after the startup, with a delay,
+// e.g. 30 seconds on desktop:
+// https://searchfox.org/mozilla-central/rev/aa46c2dcccbc6fd4265edca05d3d00cccdfc97b9/browser/components/BrowserGlue.jsm#2486-2490
+// e.g. up to 15 seconds (as an idle timeout) on Android:
+// https://searchfox.org/mozilla-central/rev/aa46c2dcccbc6fd4265edca05d3d00cccdfc97b9/mobile/android/chrome/geckoview/geckoview.js#510
+//
+// This test completes quickly if run sequentially after the many tests in this
+// directory. Otherwise the test may wait for up to MAX_DELAY_SEC seconds.
+const MAX_DELAY_SEC = 30;
+SimpleTest.requestFlakyTimeout("trackStartupCrashEnd() is called with a delay");
+
+// This test is not extension-specific, but placed in the extensions/ directory
+// because it complements the test_check_startupcache.html test, and because
+// the directory has many other tests, to minimize the amount of time wasted on
+// waiting.
+
+add_task(async function check_startup_canary() {
+ // The ".startup-incomplete" file is created at the startup, and supposedly
+ // cleared "soon" after startup (when the application knows that the startup
+ // succeeded without crash). Bug 1624724 and bug 1728461 show that this has
+ // not always been the case, so this regression test verifies that the file
+ // is actually non-existent when this test start, see
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1728461#c12
+
+ // This test is opened as a web page in the browser, so that should have been
+ // a point where the startup should have been considered done.
+
+ async function canaryExists() {
+ let chromeScript = loadChromeScript(async () => {
+ // This file is called FILE_STARTUP_INCOMPLETE in nsAppRunner.cpp and
+ // referenced via mozilla::startup::GetIncompleteStartupFile:
+ let file = Services.dirsvc.get("ProfLD", Ci.nsIFile);
+ file.append(".startup-incomplete");
+ this.sendAsyncMessage("canary_exists", file.exists());
+ });
+ let exists = await chromeScript.promiseOneMessage("canary_exists");
+ chromeScript.destroy();
+ return exists;
+ }
+
+ info("Checking if startup canary exists");
+ let i = 0;
+ while (await canaryExists()) {
+ if (i++ > MAX_DELAY_SEC) {
+ info("Canary still exists, giving up on waiting");
+ break;
+ }
+ info(`Startup canary exists, will retry ${i} / ${MAX_DELAY_SEC}.`);
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ }
+
+ is(
+ await canaryExists(),
+ false,
+ "Startup canary should have been removed after early startup"
+ );
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html b/toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html
new file mode 100644
index 0000000000..3713243c1b
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html
@@ -0,0 +1,32 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Verify non-remote mode</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+add_task(async function verify_extensions_in_parent_process() {
+ // This test ensures we are running with the proper settings.
+ const { WebExtensionPolicy } = SpecialPowers.Cu.getGlobalForObject(SpecialPowers.Services);
+ SimpleTest.ok(!WebExtensionPolicy.useRemoteWebExtensions, "extensions running in-process");
+
+ let chromeScript = SpecialPowers.loadChromeScript(() => {
+ /* eslint-env mozilla/chrome-script */
+ const { WebExtensionPolicy } = Cu.getGlobalForObject(Services);
+ Assert.ok(WebExtensionPolicy.isExtensionProcess, "parent is extension process");
+ this.sendAsyncMessage("checks_done");
+ });
+ await chromeScript.promiseOneMessage("checks_done");
+ chromeScript.destroy();
+});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html b/toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html
new file mode 100644
index 0000000000..2be0e19179
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html
@@ -0,0 +1,22 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Verify remote mode</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+ "use strict";
+ // This test ensures we are running with the proper settings.
+ const {WebExtensionPolicy} = SpecialPowers.Cu.getGlobalForObject(SpecialPowers.Services);
+ SimpleTest.ok(WebExtensionPolicy.useRemoteWebExtensions, "extensions running remote");
+ SimpleTest.ok(!WebExtensionPolicy.isExtensionProcess, "testing from remote process");
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_verify_sw_mode.html b/toolkit/components/extensions/test/mochitest/test_verify_sw_mode.html
new file mode 100644
index 0000000000..5aea44b62b
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_verify_sw_mode.html
@@ -0,0 +1,24 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Verify WebExtension background service worker mode</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+ "use strict";
+ // This test ensures we are running with the proper settings.
+ const {WebExtensionPolicy} = SpecialPowers.Cu.getGlobalForObject(SpecialPowers.Services);
+ SimpleTest.ok(WebExtensionPolicy.useRemoteWebExtensions, "extensions running remote");
+ SimpleTest.ok(!WebExtensionPolicy.isExtensionProcess, "testing from remote process");
+ SimpleTest.ok(WebExtensionPolicy.backgroundServiceWorkerEnabled, "extensions background service worker enabled");
+ SimpleTest.ok(AppConstants.MOZ_WEBEXT_WEBIDL_ENABLED, "extensions API webidl bindings enabled");
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js b/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js
new file mode 100644
index 0000000000..14d3ad2bab
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js
@@ -0,0 +1,9 @@
+"use strict";
+
+/* eslint-env worker */
+
+onmessage = function (event) {
+ fetch("https://example.com/example.txt").then(() => {
+ postMessage("Done!");
+ });
+};
diff --git a/toolkit/components/extensions/test/mochitest/webrequest_test.sys.mjs b/toolkit/components/extensions/test/mochitest/webrequest_test.sys.mjs
new file mode 100644
index 0000000000..33554f3023
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/webrequest_test.sys.mjs
@@ -0,0 +1,16 @@
+export var webrequest_test = {
+ testFetch(url) {
+ return fetch(url);
+ },
+
+ testXHR(url) {
+ return new Promise(resolve => {
+ let xhr = new XMLHttpRequest();
+ xhr.open("HEAD", url);
+ xhr.onload = () => {
+ resolve();
+ };
+ xhr.send();
+ });
+ },
+};
diff --git a/toolkit/components/extensions/test/mochitest/webrequest_worker.js b/toolkit/components/extensions/test/mochitest/webrequest_worker.js
new file mode 100644
index 0000000000..dcffd08578
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/webrequest_worker.js
@@ -0,0 +1,3 @@
+"use strict";
+
+fetch("https://example.com/example.txt");
diff --git a/toolkit/components/extensions/test/xpcshell/.eslintrc.js b/toolkit/components/extensions/test/xpcshell/.eslintrc.js
new file mode 100644
index 0000000000..60d784b53c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/.eslintrc.js
@@ -0,0 +1,13 @@
+"use strict";
+
+module.exports = {
+ env: {
+ // The tests in this folder are testing based on WebExtensions, so lets
+ // just define the webextensions environment here.
+ webextensions: true,
+ // Many parts of WebExtensions test definitions (e.g. content scripts) also
+ // interact with the browser environment, so define that here as we don't
+ // have an easy way to handle per-function/scope usage yet.
+ browser: true,
+ },
+};
diff --git a/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.sys.mjs b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.sys.mjs
new file mode 100644
index 0000000000..907631dec1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.sys.mjs
@@ -0,0 +1,62 @@
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "wdm",
+ "@mozilla.org/dom/workers/workerdebuggermanager;1",
+ "nsIWorkerDebuggerManager"
+);
+
+export class TestWorkerWatcherChild extends JSProcessActorChild {
+ async receiveMessage(msg) {
+ switch (msg.name) {
+ case "Test:StartWatchingWorkers":
+ this.startWatchingWorkers();
+ break;
+ case "Test:StopWatchingWorkers":
+ this.stopWatchingWorkers();
+ break;
+ default:
+ // Ensure the test case will fail if this JSProcessActorChild does receive
+ // unexpected messages.
+ return Promise.reject(
+ new Error(`Unexpected message received: ${msg.name}`)
+ );
+ }
+ }
+
+ startWatchingWorkers() {
+ if (!this._workerDebuggerListener) {
+ const actor = this;
+ this._workerDebuggerListener = {
+ onRegister(dbg) {
+ actor.sendAsyncMessage("Test:WorkerSpawned", {
+ workerType: dbg.type,
+ workerUrl: dbg.url,
+ });
+ },
+ onUnregister(dbg) {
+ actor.sendAsyncMessage("Test:WorkerTerminated", {
+ workerType: dbg.type,
+ workerUrl: dbg.url,
+ });
+ },
+ };
+
+ lazy.wdm.addListener(this._workerDebuggerListener);
+ }
+ }
+
+ stopWatchingWorkers() {
+ if (this._workerDebuggerListener) {
+ lazy.wdm.removeListener(this._workerDebuggerListener);
+ this._workerDebuggerListener = null;
+ }
+ }
+
+ willDestroy() {
+ this.stopWatchingWorkers();
+ }
+}
diff --git a/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.sys.mjs b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.sys.mjs
new file mode 100644
index 0000000000..a9d919f1ed
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.sys.mjs
@@ -0,0 +1,20 @@
+export class TestWorkerWatcherParent extends JSProcessActorParent {
+ constructor() {
+ super();
+ // This is set by the test helper that does use these process actors.
+ this.eventEmitter = null;
+ }
+
+ receiveMessage(msg) {
+ switch (msg.name) {
+ case "Test:WorkerSpawned":
+ this.eventEmitter?.emit("worker-spawned", msg.data);
+ break;
+ case "Test:WorkerTerminated":
+ this.eventEmitter?.emit("worker-terminated", msg.data);
+ break;
+ default:
+ throw new Error(`Unexpected message received: ${msg.name}`);
+ }
+ }
+}
diff --git a/toolkit/components/extensions/test/xpcshell/data/dummy_page.html b/toolkit/components/extensions/test/xpcshell/data/dummy_page.html
new file mode 100644
index 0000000000..c1c9a4e043
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/dummy_page.html
@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+
+<html>
+<body>
+<p>Page</p>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt b/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt
diff --git a/toolkit/components/extensions/test/xpcshell/data/file download.txt b/toolkit/components/extensions/test/xpcshell/data/file download.txt
new file mode 100644
index 0000000000..6293c7af79
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file download.txt
@@ -0,0 +1 @@
+This is a sample file used in download tests.
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html
new file mode 100644
index 0000000000..b2cf48f9e1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html
@@ -0,0 +1,25 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="stylesheet" href="file_style_good.css">
+<link rel="stylesheet" href="file_style_bad.css">
+<link rel="stylesheet" href="file_style_redirect.css">
+</head>
+<body>
+
+<div class="test">Sample text</div>
+
+<img id="img_good" src="file_image_good.png">
+<img id="img_bad" src="file_image_bad.png">
+<img id="img_redirect" src="file_image_redirect.png">
+
+<script src="file_script_good.js"></script>
+<script src="file_script_bad.js"></script>
+<script src="file_script_redirect.js"></script>
+
+<script src="nonexistent_script_url.js"></script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html
new file mode 100644
index 0000000000..f6b5142c4d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script src="http://example.org/data/file_WebRequest_permission_original.js"></script>
+<script>
+"use strict";
+
+window.parent.postMessage({
+ page: "original",
+ script: window.testScript,
+}, "*");
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js
new file mode 100644
index 0000000000..2981108b64
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js
@@ -0,0 +1,2 @@
+"use strict";
+window.testScript = "original";
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html
new file mode 100644
index 0000000000..0979593f7b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script src="http://example.org/data/file_WebRequest_permission_original.js"></script>
+<script>
+"use strict";
+
+window.parent.postMessage({
+ page: "redirected",
+ script: window.testScript,
+}, "*");
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js
new file mode 100644
index 0000000000..06fd42aa40
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js
@@ -0,0 +1,2 @@
+"use strict";
+window.testScript = "redirected";
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_content_script_errors.html b/toolkit/components/extensions/test/xpcshell/data/file_content_script_errors.html
new file mode 100644
index 0000000000..da1d1c32bc
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_content_script_errors.html
@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>Content script errors</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_csp.html b/toolkit/components/extensions/test/xpcshell/data/file_csp.html
new file mode 100644
index 0000000000..9f5cf92f5a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div id="test">Sample text</div>
+<img id="bad-image" src="http://example.org/data/file_image_bad.png">
+<script id="bad-script" src="http://example.org/data/file_script_bad.js"></script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^
new file mode 100644
index 0000000000..4c6fa3c26a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^
@@ -0,0 +1 @@
+Content-Security-Policy: default-src 'self'
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html b/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html
new file mode 100644
index 0000000000..c74dec5f5a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+<script src="http://example.net/intercept_by_webRequest.js"></script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_document_open.html b/toolkit/components/extensions/test/xpcshell/data/file_document_open.html
new file mode 100644
index 0000000000..dae5e90667
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_document_open.html
@@ -0,0 +1,21 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+ <iframe id="iframe"></iframe>
+
+ <script type="text/javascript">
+ "use strict";
+ addEventListener("load", () => {
+ let iframe = document.getElementById("iframe");
+ let doc = iframe.contentDocument;
+ doc.open("text/html");
+ doc.write("Hello.");
+ doc.close();
+ }, {once: true});
+ </script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_document_write.html b/toolkit/components/extensions/test/xpcshell/data/file_document_write.html
new file mode 100644
index 0000000000..f8369ae574
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_document_write.html
@@ -0,0 +1,36 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+ <iframe id="iframe"></iframe>
+
+ <script type="text/javascript">
+ "use strict";
+ addEventListener("load", () => {
+ // Send a heap-minimize observer notification so our script cache is
+ // cleared, and our content script isn't available for synchronous
+ // insertion.
+ window.dispatchEvent(new CustomEvent("MozHeapMinimize"));
+
+ let iframe = document.getElementById("iframe");
+ let doc = iframe.contentDocument;
+ let win = iframe.contentWindow;
+ doc.open("text/html");
+ // We need to do two writes here. The first creates the document element,
+ // which normally triggers parser blocking. The second triggers the
+ // creation of the element we're about to query for, which would normally
+ // happen asynchronously if the parser were blocked.
+ doc.write("<div id=meh>");
+ doc.write("<div id=beer></div>");
+
+ let elem = doc.getElementById("beer");
+ top.postMessage(elem instanceof win.HTMLDivElement ? "ok" : "fail",
+ "*");
+
+ doc.close();
+ }, {once: true});
+ </script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_download.html b/toolkit/components/extensions/test/xpcshell/data/file_download.html
new file mode 100644
index 0000000000..d970c63259
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_download.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div>Download HTML File</div>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_download.txt b/toolkit/components/extensions/test/xpcshell/data/file_download.txt
new file mode 100644
index 0000000000..6293c7af79
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_download.txt
@@ -0,0 +1 @@
+This is a sample file used in download tests.
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_iframe.html b/toolkit/components/extensions/test/xpcshell/data/file_iframe.html
new file mode 100644
index 0000000000..0cd68be586
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_iframe.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Iframe document</title>
+</head>
+<body>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png b/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png
new file mode 100644
index 0000000000..4c3be50847
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png
Binary files differ
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_good.png b/toolkit/components/extensions/test/xpcshell/data/file_image_good.png
new file mode 100644
index 0000000000..769c636340
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_image_good.png
Binary files differ
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png b/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png
new file mode 100644
index 0000000000..4c3be50847
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png
Binary files differ
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html b/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html
new file mode 100644
index 0000000000..387b5285f5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+"use strict";
+
+addEventListener("message", async function(event) {
+ const url = new URL("/return_headers.sjs", location).href;
+
+ const webpageFetchResult = await fetch(url).then(res => res.json());
+ const webpageXhrResult = await new Promise(resolve => {
+ const req = new XMLHttpRequest();
+ req.open("GET", url);
+ req.addEventListener("load", () => resolve(JSON.parse(req.responseText)),
+ {once: true});
+ req.addEventListener("error", () => resolve({error: "webpage xhr failed to complete"}),
+ {once: true});
+ req.send();
+ });
+
+ postMessage({
+ type: "testPageGlobals",
+ webpageFetchResult,
+ webpageXhrResult,
+ }, "*");
+}, {once: true});
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html b/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html
new file mode 100644
index 0000000000..6f1bb4648b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html
@@ -0,0 +1,61 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+"use strict";
+
+/* globals privilegedFetch, privilegedXHR */
+/* eslint-disable mozilla/balanced-listeners */
+
+addEventListener("message", function rcv(event) {
+ removeEventListener("message", rcv, false);
+
+ function assertTrue(condition, description) {
+ postMessage({msg: "assertTrue", condition, description}, "*");
+ }
+
+ function assertThrows(func, expectedError, msg) {
+ try {
+ func();
+ } catch (e) {
+ assertTrue(expectedError.test(e), msg + ": threw " + e);
+ return;
+ }
+
+ assertTrue(false, "Function did not throw, " +
+ "expected error should have matched " + expectedError);
+ }
+
+ function passListener() {
+ assertTrue(true, "Content XHR has no elevated privileges");
+ postMessage({"msg": "finish"}, "*");
+ }
+
+ function failListener() {
+ assertTrue(false, "Content XHR has no elevated privileges");
+ postMessage({"msg": "finish"}, "*");
+ }
+
+ assertThrows(function() { new privilegedXHR(); },
+ /Permission denied to access object/,
+ "Content should not be allowed to construct a privileged XHR constructor");
+
+ assertThrows(function() { new privilegedFetch(); },
+ / is not a constructor/,
+ "Content should not be allowed to construct a privileged fetch() constructor");
+
+ let req = new XMLHttpRequest();
+ req.addEventListener("load", failListener);
+ req.addEventListener("error", passListener);
+ req.open("GET", "http://example.org/example.txt");
+ req.send();
+}, false);
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html b/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html
new file mode 100644
index 0000000000..258f7058d9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+ <script type="text/javascript">
+ "use strict";
+ throw new Error(`WebExt Privilege Escalation: typeof(browser) = ${typeof(browser)}`);
+ </script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_sample.html b/toolkit/components/extensions/test/xpcshell/data/file_sample.html
new file mode 100644
index 0000000000..a20e49a1f0
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_sample.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div id="test">Sample text</div>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html b/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html
new file mode 100644
index 0000000000..9f5c5d5a6a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div id="registered-extension-url-style">Registered Extension URL style</div>
+<div id="registered-extension-text-style">Registered Extension Text style</div>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script.html b/toolkit/components/extensions/test/xpcshell/data/file_script.html
new file mode 100644
index 0000000000..8d192b7d8e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_script.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+<script type="application/javascript" src="file_script_good.js"></script>
+<script type="application/javascript" src="file_script_bad.js"></script>
+</head>
+<body>
+
+<div id="test">Sample text</div>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js b/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js
new file mode 100644
index 0000000000..ff4572865b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js
@@ -0,0 +1,12 @@
+"use strict";
+
+window.failure = true;
+window.addEventListener(
+ "load",
+ () => {
+ let el = document.createElement("div");
+ el.setAttribute("id", "bad");
+ document.body.appendChild(el);
+ },
+ { once: true }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_good.js b/toolkit/components/extensions/test/xpcshell/data/file_script_good.js
new file mode 100644
index 0000000000..bf47fb36d2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_script_good.js
@@ -0,0 +1,12 @@
+"use strict";
+
+window.success = window.success ? window.success + 1 : 1;
+window.addEventListener(
+ "load",
+ () => {
+ let el = document.createElement("div");
+ el.setAttribute("id", "good");
+ document.body.appendChild(el);
+ },
+ { once: true }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js b/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js
new file mode 100644
index 0000000000..c425122c71
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js
@@ -0,0 +1,3 @@
+"use strict";
+
+window.failure = true;
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js b/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js
new file mode 100644
index 0000000000..24a26cb8d1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js
@@ -0,0 +1,9 @@
+"use strict";
+
+var request = new XMLHttpRequest();
+request.open(
+ "get",
+ "http://example.com/browser/toolkit/modules/tests/browser/xhr_resource",
+ false
+);
+request.send();
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html b/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html
new file mode 100644
index 0000000000..c4e7db14e7
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+<div id="host">host</div>
+<script>
+ "use strict";
+ document.getElementById("host").attachShadow({mode: "closed"});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css b/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css
new file mode 100644
index 0000000000..8dbc8dc7a4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css
@@ -0,0 +1,3 @@
+#test {
+ color: green !important;
+}
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_good.css b/toolkit/components/extensions/test/xpcshell/data/file_style_good.css
new file mode 100644
index 0000000000..46f9774b5f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_style_good.css
@@ -0,0 +1,3 @@
+#test {
+ color: red;
+}
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css b/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css
new file mode 100644
index 0000000000..8dbc8dc7a4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css
@@ -0,0 +1,3 @@
+#test {
+ color: green !important;
+}
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css
new file mode 100644
index 0000000000..6a9140d97e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css
@@ -0,0 +1 @@
+:root { color: green; }
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html
new file mode 100644
index 0000000000..6d6d187a27
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html
@@ -0,0 +1,3 @@
+<!doctype html>
+<meta charset=utf-8>
+<link rel=stylesheet href=file_stylesheet_cache.css>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html
new file mode 100644
index 0000000000..07a4324c44
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html
@@ -0,0 +1,19 @@
+<!doctype html>
+<meta charset=utf-8>
+<!-- The first one should hit the cache, the second one should not. -->
+<link rel=stylesheet href=file_stylesheet_cache.css>
+<script>
+ "use strict";
+ // This script guarantees that the load of the above stylesheet has happened
+ // by now.
+ //
+ // Now we can go ahead and load the other one programmatically. It's
+ // important that we don't just throw a <link> in the markup below to
+ // guarantee
+ // that the load happens afterwards (that is, to cheat the parser's speculative
+ // load mechanism).
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = "file_stylesheet_cache.css?2";
+ document.head.appendChild(link);
+</script>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html b/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html
new file mode 100644
index 0000000000..d93813d0f5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Top-level frame document</title>
+</head>
+<body>
+ <iframe src="file_iframe.html"></iframe>
+ <iframe src="about:blank"></iframe>
+ <iframe srcdoc="Iframe srcdoc"></iframe>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_with_iframe.html b/toolkit/components/extensions/test/xpcshell/data/file_with_iframe.html
new file mode 100644
index 0000000000..705350d55c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_with_iframe.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title>file with iframe</title>
+ </head>
+ <body>
+ <div id="test"></div>
+ <iframe src="./file_sample.html"></iframe>
+ </body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html b/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html
new file mode 100644
index 0000000000..199c2ce4d4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Document with example.org frame</title>
+</head>
+<body>
+ <iframe src="http://example.org/data/file_iframe.html"></iframe>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz b/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz
new file mode 100644
index 0000000000..9eb8d73d50
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz
Binary files differ
diff --git a/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif b/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif
new file mode 100644
index 0000000000..baf8166dae
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif
Binary files differ
diff --git a/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif b/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif
new file mode 100644
index 0000000000..48f97f74bd
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif
Binary files differ
diff --git a/toolkit/components/extensions/test/xpcshell/head.js b/toolkit/components/extensions/test/xpcshell/head.js
new file mode 100644
index 0000000000..10be62a7e2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head.js
@@ -0,0 +1,354 @@
+"use strict";
+/* exported createHttpServer, cleanupDir, clearCache, optionalPermissionsPromptHandler, promiseConsoleOutput,
+ promiseQuotaManagerServiceReset, promiseQuotaManagerServiceClear,
+ runWithPrefs, testEnv, withHandlingUserInput, resetHandlingUserInput,
+ assertPersistentListeners, promiseExtensionEvent, assertHasPersistedScriptsCachedFlag,
+ assertIsPersistedScriptsCachedFlag
+*/
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var {
+ clearInterval,
+ clearTimeout,
+ setInterval,
+ setIntervalWithTarget,
+ setTimeout,
+ setTimeoutWithTarget,
+} = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
+var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ContentTask: "resource://testing-common/ContentTask.sys.mjs",
+ Extension: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+ ExtensionTestUtils:
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ MessageChannel: "resource://testing-common/MessageChannel.sys.mjs",
+ PromiseTestUtils: "resource://testing-common/PromiseTestUtils.sys.mjs",
+ Schemas: "resource://gre/modules/Schemas.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+
+// Persistent Listener test functionality
+const { assertPersistentListeners } = ExtensionTestUtils.testAssertions;
+
+// https_first automatically upgrades http to https, but the tests are not
+// designed to expect that. And it is not easy to change that because
+// nsHttpServer does not support https (bug 1742061). So disable https_first.
+Services.prefs.setBoolPref("dom.security.https_first", false);
+
+// These values may be changed in later head files and tested in check_remote
+// below.
+Services.prefs.setBoolPref("browser.tabs.remote.autostart", false);
+Services.prefs.setBoolPref("extensions.webextensions.remote", false);
+const testEnv = {
+ expectRemote: false,
+};
+
+add_setup(function check_remote() {
+ Assert.equal(
+ WebExtensionPolicy.useRemoteWebExtensions,
+ testEnv.expectRemote,
+ "useRemoteWebExtensions matches"
+ );
+ Assert.equal(
+ WebExtensionPolicy.isExtensionProcess,
+ !testEnv.expectRemote,
+ "testing from extension process"
+ );
+});
+
+ExtensionTestUtils.init(this);
+
+var createHttpServer = (...args) => {
+ AddonTestUtils.maybeInit(this);
+ return AddonTestUtils.createHttpServer(...args);
+};
+
+if (AppConstants.platform === "android") {
+ Services.io.offline = true;
+}
+
+/**
+ * Clears the HTTP and content image caches.
+ */
+function clearCache() {
+ Services.cache2.clear();
+
+ let imageCache = Cc["@mozilla.org/image/tools;1"]
+ .getService(Ci.imgITools)
+ .getImgCacheForDocument(null);
+ imageCache.clearCache(false);
+}
+
+var promiseConsoleOutput = async function (task) {
+ const DONE = `=== console listener ${Math.random()} done ===`;
+
+ let listener;
+ let messages = [];
+ let awaitListener = new Promise(resolve => {
+ listener = msg => {
+ if (msg == DONE) {
+ resolve();
+ } else {
+ void (msg instanceof Ci.nsIConsoleMessage);
+ void (msg instanceof Ci.nsIScriptError);
+ messages.push(msg);
+ }
+ };
+ });
+
+ Services.console.registerListener(listener);
+ try {
+ let result = await task();
+
+ Services.console.logStringMessage(DONE);
+ await awaitListener;
+
+ return { messages, result };
+ } finally {
+ Services.console.unregisterListener(listener);
+ }
+};
+
+// Attempt to remove a directory. If the Windows OS is still using the
+// file sometimes remove() will fail. So try repeatedly until we can
+// remove it or we give up.
+function cleanupDir(dir) {
+ let count = 0;
+ return new Promise((resolve, reject) => {
+ function tryToRemoveDir() {
+ count += 1;
+ try {
+ dir.remove(true);
+ } catch (e) {
+ // ignore
+ }
+ if (!dir.exists()) {
+ return resolve();
+ }
+ if (count >= 25) {
+ return reject(`Failed to cleanup directory: ${dir}`);
+ }
+ setTimeout(tryToRemoveDir, 100);
+ }
+ tryToRemoveDir();
+ });
+}
+
+// Run a test with the specified preferences and then restores their initial values
+// right after the test function run (whether it passes or fails).
+async function runWithPrefs(prefsToSet, testFn) {
+ const setPrefs = prefs => {
+ for (let [pref, value] of prefs) {
+ if (value === undefined) {
+ // Clear any pref that didn't have a user value.
+ info(`Clearing pref "${pref}"`);
+ Services.prefs.clearUserPref(pref);
+ continue;
+ }
+
+ info(`Setting pref "${pref}": ${value}`);
+ switch (typeof value) {
+ case "boolean":
+ Services.prefs.setBoolPref(pref, value);
+ break;
+ case "number":
+ Services.prefs.setIntPref(pref, value);
+ break;
+ case "string":
+ Services.prefs.setStringPref(pref, value);
+ break;
+ default:
+ throw new Error("runWithPrefs doesn't support this pref type yet");
+ }
+ }
+ };
+
+ const getPrefs = prefs => {
+ return prefs.map(([pref, value]) => {
+ info(`Getting initial pref value for "${pref}"`);
+ if (!Services.prefs.prefHasUserValue(pref)) {
+ // Check if the pref doesn't have a user value.
+ return [pref, undefined];
+ }
+ switch (typeof value) {
+ case "boolean":
+ return [pref, Services.prefs.getBoolPref(pref)];
+ case "number":
+ return [pref, Services.prefs.getIntPref(pref)];
+ case "string":
+ return [pref, Services.prefs.getStringPref(pref)];
+ default:
+ throw new Error("runWithPrefs doesn't support this pref type yet");
+ }
+ });
+ };
+
+ let initialPrefsValues = [];
+
+ try {
+ initialPrefsValues = getPrefs(prefsToSet);
+
+ setPrefs(prefsToSet);
+
+ await testFn();
+ } finally {
+ info("Restoring initial preferences values on exit");
+ setPrefs(initialPrefsValues);
+ }
+}
+
+// "Handling User Input" test helpers.
+
+let extensionHandlers = new WeakSet();
+
+function handlingUserInputFrameScript() {
+ /* globals content */
+ // eslint-disable-next-line no-shadow
+ const { MessageChannel } = ChromeUtils.importESModule(
+ "resource://testing-common/MessageChannel.sys.mjs"
+ );
+
+ let handle;
+ MessageChannel.addListener(this, "ExtensionTest:HandleUserInput", {
+ receiveMessage({ name, data }) {
+ if (data) {
+ handle = content.windowUtils.setHandlingUserInput(true);
+ } else if (handle) {
+ handle.destruct();
+ handle = null;
+ }
+ },
+ });
+}
+
+// If you use withHandlingUserInput then restart the addon manager,
+// you need to reset this before using withHandlingUserInput again.
+function resetHandlingUserInput() {
+ extensionHandlers = new WeakSet();
+}
+
+async function withHandlingUserInput(extension, fn) {
+ let { messageManager } = extension.extension.groupFrameLoader;
+
+ if (!extensionHandlers.has(extension)) {
+ messageManager.loadFrameScript(
+ `data:,(${encodeURI(handlingUserInputFrameScript)}).call(this)`,
+ false,
+ true
+ );
+ extensionHandlers.add(extension);
+ }
+
+ await MessageChannel.sendMessage(
+ messageManager,
+ "ExtensionTest:HandleUserInput",
+ true
+ );
+ await fn();
+ await MessageChannel.sendMessage(
+ messageManager,
+ "ExtensionTest:HandleUserInput",
+ false
+ );
+}
+
+// QuotaManagerService test helpers.
+
+function promiseQuotaManagerServiceReset() {
+ info("Calling QuotaManagerService.reset to enforce new test storage limits");
+ return new Promise(resolve => {
+ Services.qms.reset().callback = resolve;
+ });
+}
+
+function promiseQuotaManagerServiceClear() {
+ info(
+ "Calling QuotaManagerService.clear to empty the test data and refresh test storage limits"
+ );
+ return new Promise(resolve => {
+ Services.qms.clear().callback = resolve;
+ });
+}
+
+// Optional Permission prompt handling
+const optionalPermissionsPromptHandler = {
+ sawPrompt: false,
+ acceptPrompt: false,
+
+ init() {
+ Services.prefs.setBoolPref(
+ "extensions.webextOptionalPermissionPrompts",
+ true
+ );
+ Services.obs.addObserver(this, "webextension-optional-permission-prompt");
+ registerCleanupFunction(() => {
+ Services.obs.removeObserver(
+ this,
+ "webextension-optional-permission-prompt"
+ );
+ Services.prefs.clearUserPref(
+ "extensions.webextOptionalPermissionPrompts"
+ );
+ });
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "webextension-optional-permission-prompt") {
+ this.sawPrompt = true;
+ let { resolve } = subject.wrappedJSObject;
+ resolve(this.acceptPrompt);
+ }
+ },
+};
+
+function promiseExtensionEvent(wrapper, event) {
+ return new Promise(resolve => {
+ wrapper.extension.once(event, (...args) => resolve(args));
+ });
+}
+
+async function assertHasPersistedScriptsCachedFlag(ext) {
+ const { StartupCache } = ExtensionParent;
+ const allCachedGeneral = StartupCache._data.get("general");
+ equal(
+ allCachedGeneral
+ .get(ext.id)
+ ?.get(ext.version)
+ ?.get("scripting")
+ ?.has("hasPersistedScripts"),
+ true,
+ "Expect the StartupCache to include hasPersistedScripts flag"
+ );
+}
+
+async function assertIsPersistentScriptsCachedFlag(ext, expectedValue) {
+ const { StartupCache } = ExtensionParent;
+ const allCachedGeneral = StartupCache._data.get("general");
+ equal(
+ allCachedGeneral
+ .get(ext.id)
+ ?.get(ext.version)
+ ?.get("scripting")
+ ?.get("hasPersistedScripts"),
+ expectedValue,
+ "Expected cached value set on hasPersistedScripts flag"
+ );
+}
diff --git a/toolkit/components/extensions/test/xpcshell/head_dnr.js b/toolkit/components/extensions/test/xpcshell/head_dnr.js
new file mode 100644
index 0000000000..0c65869722
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_dnr.js
@@ -0,0 +1,203 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* exported assertDNRStoreData, getDNRRule, getSchemaNormalizedRule, getSchemaNormalizedRules
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ Schemas: "resource://gre/modules/Schemas.sys.mjs",
+});
+
+function getDNRRule({
+ id = 1,
+ priority = 1,
+ action = {},
+ condition = {},
+} = {}) {
+ return {
+ id,
+ priority,
+ action: {
+ type: "block",
+ ...action,
+ },
+ condition: {
+ ...condition,
+ },
+ };
+}
+
+const getSchemaNormalizedRule = (extensionTestWrapper, value) => {
+ const { extension } = extensionTestWrapper;
+ const validationContext = {
+ url: extension.baseURI.spec,
+ principal: extension.principal,
+ logError: err => {
+ // We don't expect this test helper function to be called on invalid rules,
+ // and so we trigger an explicit test failure if we ever hit any.
+ Assert.ok(
+ false,
+ `Unexpected logError on normalizing DNR rule ${JSON.stringify(
+ value
+ )} - ${err}`
+ );
+ },
+ preprocessors: {},
+ manifestVersion: extension.manifestVersion,
+ };
+
+ return Schemas.normalize(
+ value,
+ "declarativeNetRequest.Rule",
+ validationContext
+ );
+};
+
+const getSchemaNormalizedRules = (extensionTestWrapper, rules) => {
+ return rules.map(rule => {
+ const normalized = getSchemaNormalizedRule(extensionTestWrapper, rule);
+ if (normalized.error) {
+ throw new Error(
+ `Unexpected DNR Rule normalization error: ${normalized.error}`
+ );
+ }
+ return normalized.value;
+ });
+};
+
+const assertDNRStoreData = async (
+ dnrStore,
+ extensionTestWrapper,
+ expectedRulesets,
+ { assertIndividualRules = true } = {}
+) => {
+ const extUUID = extensionTestWrapper.uuid;
+ const rule_resources =
+ extensionTestWrapper.extension.manifest.declarative_net_request
+ ?.rule_resources;
+ const expectedRulesetIds = Array.from(Object.keys(expectedRulesets));
+ const expectedRulesetIndexesMap = expectedRulesetIds.reduce((acc, rsId) => {
+ acc.set(
+ rsId,
+ rule_resources.findIndex(rr => rr.id === rsId)
+ );
+ return acc;
+ }, new Map());
+
+ ok(
+ dnrStore._dataPromises.has(extUUID),
+ "Got promise for the test extension DNR data being loaded"
+ );
+
+ await dnrStore._dataPromises.get(extUUID);
+
+ ok(dnrStore._data.has(extUUID), "Got data for the test extension");
+
+ const dnrExtData = dnrStore._data.get(extUUID);
+ Assert.deepEqual(
+ {
+ schemaVersion: dnrExtData.schemaVersion,
+ extVersion: dnrExtData.extVersion,
+ },
+ {
+ schemaVersion: dnrExtData.constructor.VERSION,
+ extVersion: extensionTestWrapper.extension.version,
+ },
+ "Got the expected data schema version and extension version in the store data"
+ );
+ Assert.deepEqual(
+ Array.from(dnrExtData.staticRulesets.keys()),
+ expectedRulesetIds,
+ "Got the enabled rulesets in the stored data staticRulesets Map"
+ );
+
+ for (const rulesetId of expectedRulesetIds) {
+ const expectedRulesetIdx = expectedRulesetIndexesMap.get(rulesetId);
+ const expectedRulesetRules = getSchemaNormalizedRules(
+ extensionTestWrapper,
+ expectedRulesets[rulesetId]
+ );
+ const actualData = dnrExtData.staticRulesets.get(rulesetId);
+ equal(
+ actualData.idx,
+ expectedRulesetIdx,
+ `Got the expected ruleset index for ruleset id ${rulesetId}`
+ );
+
+ // Asserting an entire array of rules all at once will produce
+ // a big enough output to don't be immediately useful to investigate
+ // failures, asserting each rule individually would produce more
+ // readable assertion failure logs.
+ const assertRuleAtIdx = ruleIdx => {
+ const actualRule = actualData.rules[ruleIdx];
+ const expectedRule = expectedRulesetRules[ruleIdx];
+ Assert.deepEqual(
+ actualRule,
+ expectedRule,
+ `Got the expected rule at index ${ruleIdx} for ruleset id "${rulesetId}"`
+ );
+ Assert.equal(
+ actualRule.constructor.name,
+ "Rule",
+ `Expect rule at index ${ruleIdx} to be an instance of the Rule class`
+ );
+ if (expectedRule.condition.regexFilter) {
+ const compiledRegexFilter =
+ actualData.rules[ruleIdx].condition.getCompiledRegexFilter();
+ Assert.equal(
+ compiledRegexFilter?.constructor.name,
+ "RegExp",
+ `Expect rule ${ruleIdx} condition.getCompiledRegexFilter() to return a compiled regexp filter`
+ );
+ Assert.equal(
+ compiledRegexFilter?.source,
+ new RegExp(expectedRule.condition.regexFilter).source,
+ `Expect rule ${ruleIdx} condition's compiled RegExp source to match the regexFilter string`
+ );
+ Assert.equal(
+ compiledRegexFilter?.ignoreCase,
+ !expectedRule.condition.isUrlFilterCaseSensitive,
+ `Expect rule ${ruleIdx} conditions's compiled RegExp ignoreCase to be set based on condition.isUrlFilterCaseSensitive`
+ );
+ }
+ };
+
+ // Some tests may be using a big enough number of rules that
+ // the assertiongs would be producing a huge amount of log spam,
+ // and so for those tests we only explicitly assert the first
+ // and last rule and that the total amount of rules matches the
+ // expected number of rules (there are still other tests explicitly
+ // asserting all loaded rules).
+ if (assertIndividualRules) {
+ info(`Verify each individual rule loaded for ruleset id "${rulesetId}"`);
+ for (let ruleIdx = 0; ruleIdx < expectedRulesetRules.length; ruleIdx++) {
+ assertRuleAtIdx(ruleIdx);
+ }
+ } else if (expectedRulesetRules.length) {
+ // NOTE: Only asserting the first and last rule also helps to speed up
+ // the test is some slower builds when the number of expected rules is
+ // big enough (e.g. the test task verifying the enforced rule count limits
+ // was timing out in tsan build because asserting all indidual rules was
+ // taking long enough and the event page was being suspended on the idle
+ // timeout by the time we did run all these assertion and proceeding with
+ // the rest of the test task assertions), we still confirm that the total
+ // number of expected vs actual rules also matches right after these
+ // assertions.
+ info(
+ `Verify the first and last rules loaded for ruleset id "${rulesetId}"`
+ );
+ const lastExpectedRuleIdx = expectedRulesetRules.length - 1;
+ for (const ruleIdx of [0, lastExpectedRuleIdx]) {
+ assertRuleAtIdx(ruleIdx);
+ }
+ }
+
+ equal(
+ actualData.rules.length,
+ expectedRulesetRules.length,
+ `Got the expected number of rules loaded for ruleset id "${rulesetId}"`
+ );
+ }
+};
diff --git a/toolkit/components/extensions/test/xpcshell/head_e10s.js b/toolkit/components/extensions/test/xpcshell/head_e10s.js
new file mode 100644
index 0000000000..196afae7c9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_e10s.js
@@ -0,0 +1,8 @@
+"use strict";
+
+/* globals ExtensionTestUtils */
+
+// xpcshell disables e10s by default. Turn it on.
+Services.prefs.setBoolPref("browser.tabs.remote.autostart", true);
+
+ExtensionTestUtils.remoteContentScripts = true;
diff --git a/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js b/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js
new file mode 100644
index 0000000000..8bb39c0452
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js
@@ -0,0 +1,13 @@
+"use strict";
+
+// Bug 1646182: Test the legacy ExtensionPermission backend until we fully
+// migrate to rkv
+
+{
+ const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+ );
+
+ ExtensionPermissions._useLegacyStorageBackend = true;
+ ExtensionPermissions._uninit();
+}
diff --git a/toolkit/components/extensions/test/xpcshell/head_native_messaging.js b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js
new file mode 100644
index 0000000000..32b6948033
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js
@@ -0,0 +1,152 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* globals AppConstants, FileUtils */
+/* exported getSubprocessCount, setupHosts, waitForSubprocessExit */
+
+ChromeUtils.defineESModuleGetters(this, {
+ MockRegistry: "resource://testing-common/MockRegistry.sys.mjs",
+});
+if (AppConstants.platform == "win") {
+ ChromeUtils.defineESModuleGetters(this, {
+ SubprocessImpl: "resource://gre/modules/subprocess/subprocess_win.sys.mjs",
+ });
+} else {
+ ChromeUtils.defineESModuleGetters(this, {
+ SubprocessImpl: "resource://gre/modules/subprocess/subprocess_unix.sys.mjs",
+ });
+}
+
+const { Subprocess } = ChromeUtils.importESModule(
+ "resource://gre/modules/Subprocess.sys.mjs"
+);
+
+// It's important that we use a space in this directory name to make sure we
+// correctly handle executing batch files with spaces in their path.
+let tmpDir = FileUtils.getDir("TmpD", ["Native Messaging"]);
+tmpDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+const TYPE_SLUG =
+ AppConstants.platform === "linux"
+ ? "native-messaging-hosts"
+ : "NativeMessagingHosts";
+
+add_setup(async function setup() {
+ await IOUtils.makeDirectory(PathUtils.join(tmpDir.path, TYPE_SLUG));
+});
+
+registerCleanupFunction(async () => {
+ await IOUtils.remove(tmpDir.path, { recursive: true });
+});
+
+function getPath(filename) {
+ return PathUtils.join(tmpDir.path, TYPE_SLUG, filename);
+}
+
+const ID = "native@tests.mozilla.org";
+
+async function setupHosts(scripts) {
+ const pythonPath = await Subprocess.pathSearch(Services.env.get("PYTHON"));
+
+ async function writeManifest(script, scriptPath, path) {
+ let body = `#!${pythonPath} -u\n${script.script}`;
+
+ await IOUtils.writeUTF8(scriptPath, body);
+ await IOUtils.setPermissions(scriptPath, 0o755);
+
+ let manifest = {
+ name: script.name,
+ description: script.description,
+ path,
+ type: "stdio",
+ allowed_extensions: [ID],
+ };
+
+ // Optionally, allow the test to change the manifest before writing.
+ script._hookModifyManifest?.(manifest);
+
+ let manifestPath = getPath(`${script.name}.json`);
+ await IOUtils.writeJSON(manifestPath, manifest);
+
+ return manifestPath;
+ }
+
+ switch (AppConstants.platform) {
+ case "macosx":
+ case "linux":
+ let dirProvider = {
+ getFile(property) {
+ if (property == "XREUserNativeManifests") {
+ return tmpDir.clone();
+ } else if (property == "XRESysNativeManifests") {
+ return tmpDir.clone();
+ }
+ return null;
+ },
+ };
+
+ Services.dirsvc.registerProvider(dirProvider);
+ registerCleanupFunction(() => {
+ Services.dirsvc.unregisterProvider(dirProvider);
+ });
+
+ for (let script of scripts) {
+ let path = getPath(`${script.name}.py`);
+
+ await writeManifest(script, path, path);
+ }
+ break;
+
+ case "win":
+ const REGKEY = String.raw`Software\Mozilla\NativeMessagingHosts`;
+
+ let registry = new MockRegistry();
+ registerCleanupFunction(() => {
+ registry.shutdown();
+ });
+
+ for (let script of scripts) {
+ let { scriptExtension = "bat" } = script;
+
+ // It's important that we use a space in this filename. See directory
+ // name comment above.
+ let batPath = getPath(`batch ${script.name}.${scriptExtension}`);
+ let scriptPath = getPath(`${script.name}.py`);
+
+ let batBody = `@ECHO OFF\n${pythonPath} -u "${scriptPath}" %*\n`;
+ await IOUtils.writeUTF8(batPath, batBody);
+
+ let manifestPath = await writeManifest(script, scriptPath, batPath);
+
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGKEY}\\${script.name}`,
+ "",
+ manifestPath
+ );
+ }
+ break;
+
+ default:
+ ok(
+ false,
+ `Native messaging is not supported on ${AppConstants.platform}`
+ );
+ }
+}
+
+function getSubprocessCount() {
+ return SubprocessImpl.Process.getWorker()
+ .call("getProcesses", [])
+ .then(result => result.size);
+}
+function waitForSubprocessExit() {
+ return SubprocessImpl.Process.getWorker()
+ .call("waitForNoProcesses", [])
+ .then(() => {
+ // Return to the main event loop to give IO handlers enough time to consume
+ // their remaining buffered input.
+ return new Promise(resolve => setTimeout(resolve, 0));
+ });
+}
diff --git a/toolkit/components/extensions/test/xpcshell/head_remote.js b/toolkit/components/extensions/test/xpcshell/head_remote.js
new file mode 100644
index 0000000000..f9c31144c9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_remote.js
@@ -0,0 +1,7 @@
+"use strict";
+
+Services.prefs.setBoolPref("extensions.webextensions.remote", true);
+Services.prefs.setIntPref("dom.ipc.keepProcessesAlive.extension", 1);
+
+/* globals testEnv */
+testEnv.expectRemote = true; // tested in head_test.js
diff --git a/toolkit/components/extensions/test/xpcshell/head_schemas.js b/toolkit/components/extensions/test/xpcshell/head_schemas.js
new file mode 100644
index 0000000000..94af4a631a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_schemas.js
@@ -0,0 +1,129 @@
+"use strict";
+
+/* exported Schemas, LocalAPIImplementation, SchemaAPIInterface, getContextWrapper */
+
+const { Schemas } = ChromeUtils.importESModule(
+ "resource://gre/modules/Schemas.sys.mjs"
+);
+
+const { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+
+let { LocalAPIImplementation, SchemaAPIInterface } = ExtensionCommon;
+
+const contextCloneScope = this;
+
+class TallyingAPIImplementation extends SchemaAPIInterface {
+ constructor(context, namespace, name) {
+ super();
+ this.namespace = namespace;
+ this.name = name;
+ this.context = context;
+ }
+
+ callFunction(args) {
+ this.context.tally("call", this.namespace, this.name, args);
+ if (this.name === "sub_foo") {
+ return 13;
+ }
+ }
+
+ callFunctionNoReturn(args) {
+ this.context.tally("call", this.namespace, this.name, args);
+ }
+
+ getProperty() {
+ this.context.tally("get", this.namespace, this.name);
+ }
+
+ setProperty(value) {
+ this.context.tally("set", this.namespace, this.name, value);
+ }
+
+ addListener(listener, args) {
+ this.context.tally("addListener", this.namespace, this.name, [
+ listener,
+ args,
+ ]);
+ }
+
+ removeListener(listener) {
+ this.context.tally("removeListener", this.namespace, this.name, [listener]);
+ }
+
+ hasListener(listener) {
+ this.context.tally("hasListener", this.namespace, this.name, [listener]);
+ }
+}
+
+function getContextWrapper(manifestVersion = 2) {
+ return {
+ url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/",
+
+ cloneScope: contextCloneScope,
+
+ manifestVersion,
+
+ permissions: new Set(),
+ tallied: null,
+ talliedErrors: [],
+
+ tally(kind, ns, name, args) {
+ this.tallied = [kind, ns, name, args];
+ },
+
+ verify(...args) {
+ Assert.equal(JSON.stringify(this.tallied), JSON.stringify(args));
+ this.tallied = null;
+ },
+
+ checkErrors(errors) {
+ let { talliedErrors } = this;
+ Assert.equal(
+ talliedErrors.length,
+ errors.length,
+ "Got expected number of errors"
+ );
+ for (let [i, error] of errors.entries()) {
+ Assert.ok(
+ i in talliedErrors && String(talliedErrors[i]).includes(error),
+ `${JSON.stringify(error)} is a substring of error ${JSON.stringify(
+ talliedErrors[i]
+ )}`
+ );
+ }
+
+ talliedErrors.length = 0;
+ },
+
+ checkLoadURL(url) {
+ return !url.startsWith("chrome:");
+ },
+
+ preprocessors: {
+ localize(value, context) {
+ return value.replace(
+ /__MSG_(.*?)__/g,
+ (m0, m1) => `${m1.toUpperCase()}`
+ );
+ },
+ },
+
+ logError(message) {
+ this.talliedErrors.push(message);
+ },
+
+ hasPermission(permission) {
+ return this.permissions.has(permission);
+ },
+
+ shouldInject(ns, name, allowedContexts) {
+ return name != "do-not-inject";
+ },
+
+ getImplementation(namespace, name) {
+ return new TallyingAPIImplementation(this, namespace, name);
+ },
+ };
+}
diff --git a/toolkit/components/extensions/test/xpcshell/head_service_worker.js b/toolkit/components/extensions/test/xpcshell/head_service_worker.js
new file mode 100644
index 0000000000..f83250f84c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_service_worker.js
@@ -0,0 +1,158 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* exported TestWorkerWatcher */
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs",
+});
+
+// Ensure that the profile-after-change message has been notified,
+// so that ServiceWokerRegistrar is going to be initialized,
+// otherwise tests using a background service worker will fail.
+// in debug builds because of an assertion failure triggered
+// by ServiceWorkerRegistrar.cpp (due to not being initialized
+// automatically on startup as in a real Firefox instance).
+Services.obs.notifyObservers(
+ null,
+ "profile-after-change",
+ "force-serviceworkerrestart-init"
+);
+
+// A test utility class used in the test case to watch for a given extension
+// service worker being spawned and terminated (using the same kind of Firefox DevTools
+// internals that about:debugging is using to watch the workers activity).
+//
+// NOTE: this helper class does also depends from the two jsm files where the
+// Parent and Child TestWorkerWatcher actor is defined:
+//
+// - data/TestWorkerWatcherParent.jsm
+// - data/TestWorkerWatcherChild.jsm
+class TestWorkerWatcher extends ExtensionCommon.EventEmitter {
+ JS_ACTOR_NAME = "TestWorkerWatcher";
+
+ constructor(dataRelPath = "./data") {
+ super();
+ this.dataRelPath = dataRelPath;
+ this.extensionProcess = null;
+ this.extensionProcessActor = null;
+ this.registerProcessActor();
+ this.getAndWatchExtensionProcess();
+ // Observer child process creation and shutdown if the extension
+ // are meant to run in a child process.
+ Services.obs.addObserver(this, "ipc:content-created");
+ Services.obs.addObserver(this, "ipc:content-shutdown");
+ }
+
+ async destroy() {
+ await this.stopWatchingWorkers();
+ ChromeUtils.unregisterProcessActor(this.JS_ACTOR_NAME);
+ }
+
+ get swm() {
+ return Cc["@mozilla.org/serviceworkers/manager;1"].getService(
+ Ci.nsIServiceWorkerManager
+ );
+ }
+
+ getRegistration(extension) {
+ return this.swm.getRegistrationByPrincipal(
+ extension.extension.principal,
+ extension.extension.principal.spec
+ );
+ }
+
+ watchExtensionServiceWorker(extension) {
+ // These events are emitted by TestWatchExtensionWorkersParent.
+ const promiseWorkerSpawned = this.waitForEvent("worker-spawned", extension);
+ const promiseWorkerTerminated = this.waitForEvent(
+ "worker-terminated",
+ extension
+ );
+
+ // Terminate the worker sooner by settng the idle_timeout to 0,
+ // then clear the pref as soon as the worker has been terminated.
+ const terminate = () => {
+ promiseWorkerTerminated.then(() => {
+ Services.prefs.clearUserPref("dom.serviceWorkers.idle_timeout");
+ });
+ Services.prefs.setIntPref("dom.serviceWorkers.idle_timeout", 0);
+ const swReg = this.getRegistration(extension);
+ // If the active worker is already active, we have to make sure the new value
+ // set on the idle_timeout pref is picked up by ServiceWorkerPrivate::ResetIdleTimeout.
+ swReg.activeWorker?.attachDebugger();
+ swReg.activeWorker?.detachDebugger();
+ return promiseWorkerTerminated;
+ };
+
+ return {
+ promiseWorkerSpawned,
+ promiseWorkerTerminated,
+ terminate,
+ };
+ }
+
+ // Methods only used internally.
+
+ waitForEvent(event, extension) {
+ return new Promise(resolve => {
+ const listener = (_eventName, data) => {
+ if (!data.workerUrl.startsWith(extension.extension?.principal.spec)) {
+ return;
+ }
+ this.off(event, listener);
+ resolve(data);
+ };
+
+ this.on(event, listener);
+ });
+ }
+
+ registerProcessActor() {
+ const { JS_ACTOR_NAME } = this;
+ ChromeUtils.registerProcessActor(JS_ACTOR_NAME, {
+ parent: {
+ moduleURI: `resource://testing-common/${JS_ACTOR_NAME}Parent.jsm`,
+ },
+ child: {
+ moduleURI: `resource://testing-common/${JS_ACTOR_NAME}Child.jsm`,
+ },
+ });
+ }
+
+ startWatchingWorkers() {
+ if (!this.extensionProcessActor) {
+ return;
+ }
+ this.extensionProcessActor.eventEmitter = this;
+ return this.extensionProcessActor.sendQuery("Test:StartWatchingWorkers");
+ }
+
+ stopWatchingWorkers() {
+ if (!this.extensionProcessActor) {
+ return;
+ }
+ this.extensionProcessActor.eventEmitter = null;
+ return this.extensionProcessActor.sendQuery("Test:StopWatchingWorkers");
+ }
+
+ getAndWatchExtensionProcess() {
+ const extensionProcess = ChromeUtils.getAllDOMProcesses().find(p => {
+ return p.remoteType === "extension";
+ });
+ if (extensionProcess !== this.extensionProcess) {
+ this.extensionProcess = extensionProcess;
+ this.extensionProcessActor = extensionProcess
+ ? extensionProcess.getActor(this.JS_ACTOR_NAME)
+ : null;
+ this.startWatchingWorkers();
+ }
+ }
+
+ observe(subject, topic, childIDString) {
+ // Keep the watched process and related test child process actor updated
+ // when a process is created or destroyed.
+ this.getAndWatchExtensionProcess();
+ }
+}
diff --git a/toolkit/components/extensions/test/xpcshell/head_storage.js b/toolkit/components/extensions/test/xpcshell/head_storage.js
new file mode 100644
index 0000000000..139c84bf8d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_storage.js
@@ -0,0 +1,1400 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* import-globals-from head.js */
+
+const STORAGE_SYNC_PREF = "webextensions.storage.sync.enabled";
+
+// Test implementations and utility functions that are used against multiple
+// storage areas (eg, a test which is run against browser.storage.local and
+// browser.storage.sync, or a test against browser.storage.sync but needs to
+// be run against both the kinto and rust implementations.)
+
+/**
+ * Utility function to ensure that all supported APIs for getting are
+ * tested.
+ *
+ * @param {string} areaName
+ * either "local" or "sync" according to what we want to test
+ * @param {string} prop
+ * "key" to look up using the storage API
+ * @param {object} value
+ * "value" to compare against
+ */
+async function checkGetImpl(areaName, prop, value) {
+ let storage = browser.storage[areaName];
+
+ let data = await storage.get();
+ browser.test.assertEq(
+ value,
+ data[prop],
+ `unspecified getter worked for ${prop} in ${areaName}`
+ );
+
+ data = await storage.get(null);
+ browser.test.assertEq(
+ value,
+ data[prop],
+ `null getter worked for ${prop} in ${areaName}`
+ );
+
+ data = await storage.get(prop);
+ browser.test.assertEq(
+ value,
+ data[prop],
+ `string getter worked for ${prop} in ${areaName}`
+ );
+ browser.test.assertEq(
+ Object.keys(data).length,
+ 1,
+ `string getter should return an object with a single property`
+ );
+
+ data = await storage.get([prop]);
+ browser.test.assertEq(
+ value,
+ data[prop],
+ `array getter worked for ${prop} in ${areaName}`
+ );
+ browser.test.assertEq(
+ Object.keys(data).length,
+ 1,
+ `array getter with a single key should return an object with a single property`
+ );
+
+ data = await storage.get({ [prop]: undefined });
+ browser.test.assertEq(
+ value,
+ data[prop],
+ `object getter worked for ${prop} in ${areaName}`
+ );
+ browser.test.assertEq(
+ Object.keys(data).length,
+ 1,
+ `object getter with a single key should return an object with a single property`
+ );
+}
+
+function test_config_flag_needed() {
+ async function testFn() {
+ function background() {
+ let promises = [];
+ let apiTests = [
+ { method: "get", args: ["foo"] },
+ { method: "set", args: [{ foo: "bar" }] },
+ { method: "remove", args: ["foo"] },
+ { method: "clear", args: [] },
+ ];
+ apiTests.forEach(testDef => {
+ promises.push(
+ browser.test.assertRejects(
+ browser.storage.sync[testDef.method](...testDef.args),
+ "Please set webextensions.storage.sync.enabled to true in about:config",
+ `storage.sync.${testDef.method} is behind a flag`
+ )
+ );
+ });
+
+ Promise.all(promises).then(() => browser.test.notifyPass("flag needed"));
+ }
+
+ ok(
+ !Services.prefs.getBoolPref(STORAGE_SYNC_PREF, false),
+ "The `${STORAGE_SYNC_PREF}` should be set to false"
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ },
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("flag needed");
+ await extension.unload();
+ }
+
+ return runWithPrefs([[STORAGE_SYNC_PREF, false]], testFn);
+}
+
+async function test_storage_after_reload(areaName, { expectPersistency }) {
+ // Just some random extension ID that we can re-use
+ const extensionId = "my-extension-id@1";
+
+ function loadExtension() {
+ async function background(areaName) {
+ browser.test.sendMessage(
+ "initialItems",
+ await browser.storage[areaName].get(null)
+ );
+ await browser.storage[areaName].set({ a: "b" });
+ browser.test.notifyPass("set-works");
+ }
+
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: extensionId } },
+ permissions: ["storage"],
+ },
+ background: `(${background})("${areaName}")`,
+ });
+ }
+
+ let extension1 = loadExtension();
+ await extension1.startup();
+
+ Assert.deepEqual(
+ await extension1.awaitMessage("initialItems"),
+ {},
+ "No stored items at first"
+ );
+
+ await extension1.awaitFinish("set-works");
+ await extension1.unload();
+
+ let extension2 = loadExtension();
+ await extension2.startup();
+
+ Assert.deepEqual(
+ await extension2.awaitMessage("initialItems"),
+ expectPersistency ? { a: "b" } : {},
+ `Expect ${areaName} stored items ${
+ expectPersistency ? "to" : "not"
+ } be available after restart`
+ );
+
+ await extension2.awaitFinish("set-works");
+ await extension2.unload();
+}
+
+function test_sync_reloading_extensions_works() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], async () => {
+ ok(
+ Services.prefs.getBoolPref(STORAGE_SYNC_PREF, false),
+ "The `${STORAGE_SYNC_PREF}` should be set to true"
+ );
+
+ await test_storage_after_reload("sync", { expectPersistency: true });
+ });
+}
+
+async function test_background_page_storage(testAreaName) {
+ async function backgroundScript(checkGet) {
+ let globalChanges, gResolve;
+ function clearGlobalChanges() {
+ globalChanges = new Promise(resolve => {
+ gResolve = resolve;
+ });
+ }
+ clearGlobalChanges();
+ let expectedAreaName;
+
+ browser.storage.onChanged.addListener((changes, areaName) => {
+ browser.test.assertEq(
+ expectedAreaName,
+ areaName,
+ "Expected area name received by listener"
+ );
+ gResolve(changes);
+ });
+
+ async function checkChanges(areaName, changes, message) {
+ function checkSub(obj1, obj2) {
+ for (let prop in obj1) {
+ browser.test.assertTrue(
+ obj1[prop] !== undefined,
+ `checkChanges ${areaName} ${prop} is missing (${message})`
+ );
+ browser.test.assertTrue(
+ obj2[prop] !== undefined,
+ `checkChanges ${areaName} ${prop} is missing (${message})`
+ );
+ browser.test.assertEq(
+ obj1[prop].oldValue,
+ obj2[prop].oldValue,
+ `checkChanges ${areaName} ${prop} old (${message})`
+ );
+ browser.test.assertEq(
+ obj1[prop].newValue,
+ obj2[prop].newValue,
+ `checkChanges ${areaName} ${prop} new (${message})`
+ );
+ }
+ }
+
+ const recentChanges = await globalChanges;
+ checkSub(changes, recentChanges);
+ checkSub(recentChanges, changes);
+ clearGlobalChanges();
+ }
+
+ // Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1645598
+ async function testNonExistingKeys(storage, storageAreaDesc) {
+ let data = await storage.get({ test6: 6 });
+ browser.test.assertEq(
+ `{"test6":6}`,
+ JSON.stringify(data),
+ `Use default value when not stored for ${storageAreaDesc}`
+ );
+
+ data = await storage.get({ test6: null });
+ browser.test.assertEq(
+ `{"test6":null}`,
+ JSON.stringify(data),
+ `Use default value, even if null for ${storageAreaDesc}`
+ );
+
+ data = await storage.get("test6");
+ browser.test.assertEq(
+ `{}`,
+ JSON.stringify(data),
+ `Empty result if key is not found for ${storageAreaDesc}`
+ );
+
+ data = await storage.get(["test6", "test7"]);
+ browser.test.assertEq(
+ `{}`,
+ JSON.stringify(data),
+ `Empty result if list of keys is not found for ${storageAreaDesc}`
+ );
+ }
+
+ async function testFalseyValues(areaName) {
+ let storage = browser.storage[areaName];
+ const dataInitial = {
+ "test-falsey-value-bool": false,
+ "test-falsey-value-string": "",
+ "test-falsey-value-number": 0,
+ };
+ const dataUpdate = {
+ "test-falsey-value-bool": true,
+ "test-falsey-value-string": "non-empty-string",
+ "test-falsey-value-number": 10,
+ };
+
+ // Compute the expected changes.
+ const onSetInitial = {
+ "test-falsey-value-bool": { newValue: false },
+ "test-falsey-value-string": { newValue: "" },
+ "test-falsey-value-number": { newValue: 0 },
+ };
+ const onRemovedFalsey = {
+ "test-falsey-value-bool": { oldValue: false },
+ "test-falsey-value-string": { oldValue: "" },
+ "test-falsey-value-number": { oldValue: 0 },
+ };
+ const onUpdatedFalsey = {
+ "test-falsey-value-bool": { newValue: true, oldValue: false },
+ "test-falsey-value-string": {
+ newValue: "non-empty-string",
+ oldValue: "",
+ },
+ "test-falsey-value-number": { newValue: 10, oldValue: 0 },
+ };
+ const keys = Object.keys(dataInitial);
+
+ // Test on removing falsey values.
+ await storage.set(dataInitial);
+ await checkChanges(areaName, onSetInitial, "set falsey values");
+ await storage.remove(keys);
+ await checkChanges(areaName, onRemovedFalsey, "remove falsey value");
+
+ // Test on updating falsey values.
+ await storage.set(dataInitial);
+ await checkChanges(areaName, onSetInitial, "set falsey values");
+ await storage.set(dataUpdate);
+ await checkChanges(areaName, onUpdatedFalsey, "set non-falsey values");
+
+ // Clear the storage state.
+ await testNonExistingKeys(storage, `${areaName} before clearing`);
+ await storage.clear();
+ await testNonExistingKeys(storage, `${areaName} after clearing`);
+ await globalChanges;
+ clearGlobalChanges();
+ }
+
+ function CustomObj() {
+ this.testKey1 = "testValue1";
+ }
+
+ CustomObj.prototype.toString = function () {
+ return '{"testKey2":"testValue2"}';
+ };
+
+ CustomObj.prototype.toJSON = function customObjToJSON() {
+ return { testKey1: "testValue3" };
+ };
+
+ /* eslint-disable dot-notation */
+ async function runTests(areaName) {
+ expectedAreaName = areaName;
+ let storage = browser.storage[areaName];
+ // Set some data and then test getters.
+ try {
+ await storage.set({ "test-prop1": "value1", "test-prop2": "value2" });
+ await checkChanges(
+ areaName,
+ {
+ "test-prop1": { newValue: "value1" },
+ "test-prop2": { newValue: "value2" },
+ },
+ "set (a)"
+ );
+
+ await checkGet(areaName, "test-prop1", "value1");
+ await checkGet(areaName, "test-prop2", "value2");
+
+ let data = await storage.get({
+ "test-prop1": undefined,
+ "test-prop2": undefined,
+ other: "default",
+ });
+ browser.test.assertEq(
+ "value1",
+ data["test-prop1"],
+ "prop1 correct (a)"
+ );
+ browser.test.assertEq(
+ "value2",
+ data["test-prop2"],
+ "prop2 correct (a)"
+ );
+ browser.test.assertEq("default", data["other"], "other correct");
+
+ data = await storage.get(["test-prop1", "test-prop2", "other"]);
+ browser.test.assertEq(
+ "value1",
+ data["test-prop1"],
+ "prop1 correct (b)"
+ );
+ browser.test.assertEq(
+ "value2",
+ data["test-prop2"],
+ "prop2 correct (b)"
+ );
+ browser.test.assertFalse("other" in data, "other correct");
+
+ // Remove data in various ways.
+ await storage.remove("test-prop1");
+ await checkChanges(
+ areaName,
+ { "test-prop1": { oldValue: "value1" } },
+ "remove string"
+ );
+
+ data = await storage.get(["test-prop1", "test-prop2"]);
+ browser.test.assertFalse(
+ "test-prop1" in data,
+ "prop1 absent (remove string)"
+ );
+ browser.test.assertTrue(
+ "test-prop2" in data,
+ "prop2 present (remove string)"
+ );
+
+ await storage.set({ "test-prop1": "value1" });
+ await checkChanges(
+ areaName,
+ { "test-prop1": { newValue: "value1" } },
+ "set (c)"
+ );
+
+ data = await storage.get(["test-prop1", "test-prop2"]);
+ browser.test.assertEq(
+ data["test-prop1"],
+ "value1",
+ "prop1 correct (c)"
+ );
+ browser.test.assertEq(
+ data["test-prop2"],
+ "value2",
+ "prop2 correct (c)"
+ );
+
+ await storage.remove(["test-prop1", "test-prop2"]);
+ await checkChanges(
+ areaName,
+ {
+ "test-prop1": { oldValue: "value1" },
+ "test-prop2": { oldValue: "value2" },
+ },
+ "remove array"
+ );
+
+ data = await storage.get(["test-prop1", "test-prop2"]);
+ browser.test.assertFalse(
+ "test-prop1" in data,
+ "prop1 absent (remove array)"
+ );
+ browser.test.assertFalse(
+ "test-prop2" in data,
+ "prop2 absent (remove array)"
+ );
+
+ await testFalseyValues(areaName);
+
+ // test storage.clear
+ await storage.set({ "test-prop1": "value1", "test-prop2": "value2" });
+ // Make sure that set() handler happened before we clear the
+ // promise again.
+ await globalChanges;
+
+ clearGlobalChanges();
+ await storage.clear();
+
+ await checkChanges(
+ areaName,
+ {
+ "test-prop1": { oldValue: "value1" },
+ "test-prop2": { oldValue: "value2" },
+ },
+ "clear"
+ );
+ data = await storage.get(["test-prop1", "test-prop2"]);
+ browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)");
+ browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)");
+
+ // Make sure we can store complex JSON data.
+ // known previous values
+ await storage.set({ "test-prop1": "value1", "test-prop2": "value2" });
+
+ // Make sure the set() handler landed.
+ await globalChanges;
+
+ let date = new Date(0);
+
+ clearGlobalChanges();
+ await storage.set({
+ "test-prop1": {
+ str: "hello",
+ bool: true,
+ null: null,
+ undef: undefined,
+ obj: {},
+ nestedObj: {
+ testKey: {},
+ },
+ intKeyObj: {
+ 4: "testValue1",
+ 3: "testValue2",
+ 99: "testValue3",
+ },
+ floatKeyObj: {
+ 1.4: "testValue1",
+ 5.5: "testValue2",
+ },
+ customObj: new CustomObj(),
+ arr: [1, 2],
+ nestedArr: [1, [2, 3]],
+ date,
+ regexp: /regexp/,
+ },
+ });
+
+ await browser.test.assertRejects(
+ storage.set({
+ window,
+ }),
+ /DataCloneError|cyclic object value/
+ );
+
+ await browser.test.assertRejects(
+ storage.set({ "test-prop2": function func() {} }),
+ /DataCloneError/
+ );
+
+ const recentChanges = await globalChanges;
+
+ browser.test.assertEq(
+ "value1",
+ recentChanges["test-prop1"].oldValue,
+ "oldValue correct"
+ );
+ browser.test.assertEq(
+ "object",
+ typeof recentChanges["test-prop1"].newValue,
+ "newValue is obj"
+ );
+ clearGlobalChanges();
+
+ data = await storage.get({
+ "test-prop1": undefined,
+ "test-prop2": undefined,
+ });
+ let obj = data["test-prop1"];
+
+ browser.test.assertEq(
+ "object",
+ typeof obj.customObj,
+ "custom object part correct"
+ );
+ browser.test.assertEq(
+ 1,
+ Object.keys(obj.customObj).length,
+ "customObj keys correct"
+ );
+
+ if (areaName === "local" || areaName === "session") {
+ browser.test.assertEq(
+ String(date),
+ String(obj.date),
+ "date part correct"
+ );
+ browser.test.assertEq(
+ "/regexp/",
+ obj.regexp.toString(),
+ "regexp part correct"
+ );
+ // storage.local and .session don't use toJSON().
+ browser.test.assertEq(
+ "testValue1",
+ obj.customObj.testKey1,
+ "customObj keys correct"
+ );
+ } else {
+ browser.test.assertEq(
+ "1970-01-01T00:00:00.000Z",
+ String(obj.date),
+ "date part correct"
+ );
+
+ browser.test.assertEq(
+ "object",
+ typeof obj.regexp,
+ "regexp part is an object"
+ );
+ browser.test.assertEq(
+ 0,
+ Object.keys(obj.regexp).length,
+ "regexp part is an empty object"
+ );
+ // storage.sync does call toJSON
+ browser.test.assertEq(
+ "testValue3",
+ obj.customObj.testKey1,
+ "customObj keys correct"
+ );
+ }
+
+ browser.test.assertEq("hello", obj.str, "string part correct");
+ browser.test.assertEq(true, obj.bool, "bool part correct");
+ browser.test.assertEq(null, obj.null, "null part correct");
+ browser.test.assertEq(undefined, obj.undef, "undefined part correct");
+ browser.test.assertEq(undefined, obj.window, "window part correct");
+ browser.test.assertEq("object", typeof obj.obj, "object part correct");
+ browser.test.assertEq(
+ "object",
+ typeof obj.nestedObj,
+ "nested object part correct"
+ );
+ browser.test.assertEq(
+ "object",
+ typeof obj.nestedObj.testKey,
+ "nestedObj.testKey part correct"
+ );
+ browser.test.assertEq(
+ "object",
+ typeof obj.intKeyObj,
+ "int key object part correct"
+ );
+ browser.test.assertEq(
+ "testValue1",
+ obj.intKeyObj[4],
+ "intKeyObj[4] part correct"
+ );
+ browser.test.assertEq(
+ "testValue2",
+ obj.intKeyObj[3],
+ "intKeyObj[3] part correct"
+ );
+ browser.test.assertEq(
+ "testValue3",
+ obj.intKeyObj[99],
+ "intKeyObj[99] part correct"
+ );
+ browser.test.assertEq(
+ "object",
+ typeof obj.floatKeyObj,
+ "float key object part correct"
+ );
+ browser.test.assertEq(
+ "testValue1",
+ obj.floatKeyObj[1.4],
+ "floatKeyObj[1.4] part correct"
+ );
+ browser.test.assertEq(
+ "testValue2",
+ obj.floatKeyObj[5.5],
+ "floatKeyObj[5.5] part correct"
+ );
+
+ browser.test.assertTrue(Array.isArray(obj.arr), "array part present");
+ browser.test.assertEq(1, obj.arr[0], "arr[0] part correct");
+ browser.test.assertEq(2, obj.arr[1], "arr[1] part correct");
+ browser.test.assertEq(2, obj.arr.length, "arr.length part correct");
+ browser.test.assertTrue(
+ Array.isArray(obj.nestedArr),
+ "nested array part present"
+ );
+ browser.test.assertEq(
+ 2,
+ obj.nestedArr.length,
+ "nestedArr.length part correct"
+ );
+ browser.test.assertEq(1, obj.nestedArr[0], "nestedArr[0] part correct");
+ browser.test.assertTrue(
+ Array.isArray(obj.nestedArr[1]),
+ "nestedArr[1] part present"
+ );
+ browser.test.assertEq(
+ 2,
+ obj.nestedArr[1].length,
+ "nestedArr[1].length part correct"
+ );
+ browser.test.assertEq(
+ 2,
+ obj.nestedArr[1][0],
+ "nestedArr[1][0] part correct"
+ );
+ browser.test.assertEq(
+ 3,
+ obj.nestedArr[1][1],
+ "nestedArr[1][1] part correct"
+ );
+ } catch (e) {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ browser.test.notifyFail("storage");
+ }
+ }
+
+ browser.test.onMessage.addListener(msg => {
+ let promise;
+ if (msg === "test-local") {
+ promise = runTests("local");
+ } else if (msg === "test-sync") {
+ promise = runTests("sync");
+ } else if (msg === "test-session") {
+ promise = runTests("session");
+ }
+ promise.then(() => browser.test.sendMessage("test-finished"));
+ });
+
+ browser.test.sendMessage("ready");
+ }
+
+ let extensionData = {
+ background: `(${backgroundScript})(${checkGetImpl})`,
+ manifest: {
+ permissions: ["storage"],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ extension.sendMessage(`test-${testAreaName}`);
+ await extension.awaitMessage("test-finished");
+
+ await extension.unload();
+}
+
+function test_storage_sync_requires_real_id() {
+ async function testFn() {
+ async function background() {
+ const EXCEPTION_MESSAGE =
+ "The storage API will not work with a temporary addon ID. " +
+ "Please add an explicit addon ID to your manifest. " +
+ "For more information see https://mzl.la/3lPk1aE.";
+
+ await browser.test.assertRejects(
+ browser.storage.sync.set({ foo: "bar" }),
+ EXCEPTION_MESSAGE
+ );
+
+ browser.test.notifyPass("exception correct");
+ }
+
+ let extensionData = {
+ background,
+ manifest: {
+ permissions: ["storage"],
+ },
+ useAddonManager: "temporary",
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitFinish("exception correct");
+
+ await extension.unload();
+ }
+
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], testFn);
+}
+
+// Test for storage areas which don't support getBytesInUse() nor QUOTA
+// constants.
+async function check_storage_area_no_bytes_in_use(area) {
+ let impl = browser.storage[area];
+
+ browser.test.assertEq(
+ typeof impl.getBytesInUse,
+ "undefined",
+ "getBytesInUse API method should not be available"
+ );
+ browser.test.sendMessage("test-complete");
+}
+
+async function test_background_storage_area_no_bytes_in_use(area) {
+ const EXT_ID = "test-gbiu@mozilla.org";
+
+ const extensionDef = {
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: { gecko: { id: EXT_ID } },
+ },
+ background: `(${check_storage_area_no_bytes_in_use})("${area}")`,
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(extensionDef);
+
+ await extension.startup();
+ await extension.awaitMessage("test-complete");
+ await extension.unload();
+}
+
+async function test_contentscript_storage_area_no_bytes_in_use(area) {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ function contentScript(checkImpl) {
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "test-local") {
+ checkImpl("local");
+ } else if (msg === "test-sync") {
+ checkImpl("sync");
+ } else if (msg === "test-session") {
+ checkImpl("session");
+ } else {
+ browser.test.fail(`Unexpected test message received: ${msg}`);
+ browser.test.sendMessage("test-complete");
+ }
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+
+ permissions: ["storage"],
+ },
+
+ files: {
+ "content_script.js": `(${contentScript})(${check_storage_area_no_bytes_in_use})`,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ extension.sendMessage(`test-${area}`);
+ await extension.awaitMessage("test-complete");
+
+ await extension.unload();
+ await contentPage.close();
+}
+
+// Test for storage areas which do support getBytesInUse() (but which may or may
+// not support enforcement of the quota)
+async function check_storage_area_with_bytes_in_use(area, expectQuota) {
+ let impl = browser.storage[area];
+
+ // QUOTA_* constants aren't currently exposed - see bug 1396810.
+ // However, the quotas are still enforced, so test them here.
+ // (Note that an implication of this is that we can't test area other than
+ // 'sync', because its limits are different - so for completeness...)
+ browser.test.assertEq(
+ area,
+ "sync",
+ "Running test on storage.sync API as expected"
+ );
+ const QUOTA_BYTES_PER_ITEM = 8192;
+ const MAX_ITEMS = 512;
+
+ // bytes is counted as "length of key as a string, length of value as
+ // JSON" - ie, quotes not counted in the key, but are in the value.
+ let value = "x".repeat(QUOTA_BYTES_PER_ITEM - 3);
+
+ await impl.set({ x: value }); // Shouldn't reject on either kinto or rust-based storage.sync.
+ browser.test.assertEq(await impl.getBytesInUse(null), QUOTA_BYTES_PER_ITEM);
+ // kinto does implement getBytesInUse() but doesn't enforce a quota.
+ if (expectQuota) {
+ await browser.test.assertRejects(
+ impl.set({ x: value + "x" }),
+ /QuotaExceededError/,
+ "Got a rejection with the expected error message"
+ );
+ // MAX_ITEMS
+ await impl.clear();
+ let ob = {};
+ for (let i = 0; i < MAX_ITEMS; i++) {
+ ob[`key-${i}`] = "x";
+ }
+ await impl.set(ob); // should work.
+ await browser.test.assertRejects(
+ impl.set({ straw: "camel's back" }), // exceeds MAX_ITEMS
+ /QuotaExceededError/,
+ "Got a rejection with the expected error message"
+ );
+ // QUOTA_BYTES is being already tested for the underlying StorageSyncService
+ // so we don't duplicate those tests here.
+ } else {
+ // Exceeding quota should work on the previous kinto-based storage.sync implementation
+ await impl.set({ x: value + "x" }); // exceeds quota but should work.
+ browser.test.assertEq(
+ await impl.getBytesInUse(null),
+ QUOTA_BYTES_PER_ITEM + 1,
+ "Got the expected result from getBytesInUse"
+ );
+ }
+ browser.test.sendMessage("test-complete");
+}
+
+async function test_background_storage_area_with_bytes_in_use(
+ area,
+ expectQuota
+) {
+ const EXT_ID = "test-gbiu@mozilla.org";
+
+ const extensionDef = {
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: { gecko: { id: EXT_ID } },
+ },
+ background: `(${check_storage_area_with_bytes_in_use})("${area}", ${expectQuota})`,
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(extensionDef);
+
+ await extension.startup();
+ await extension.awaitMessage("test-complete");
+ await extension.unload();
+}
+
+async function test_contentscript_storage_area_with_bytes_in_use(
+ area,
+ expectQuota
+) {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ function contentScript(checkImpl) {
+ browser.test.onMessage.addListener(([area, expectQuota]) => {
+ if (
+ !["local", "sync"].includes(area) ||
+ typeof expectQuota !== "boolean"
+ ) {
+ browser.test.fail(`Unexpected test message: [${area}, ${expectQuota}]`);
+ // Let the test to fail immediately instead of wait for a timeout failure.
+ browser.test.sendMessage("test-complete");
+ return;
+ }
+ checkImpl(area, expectQuota);
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+
+ permissions: ["storage"],
+ },
+
+ files: {
+ "content_script.js": `(${contentScript})(${check_storage_area_with_bytes_in_use})`,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ extension.sendMessage([area, expectQuota]);
+ await extension.awaitMessage("test-complete");
+
+ await extension.unload();
+ await contentPage.close();
+}
+
+// A couple of common tests for checking content scripts.
+async function testStorageContentScript(checkGet) {
+ let globalChanges, gResolve;
+ function clearGlobalChanges() {
+ globalChanges = new Promise(resolve => {
+ gResolve = resolve;
+ });
+ }
+ clearGlobalChanges();
+ let expectedAreaName;
+
+ browser.storage.onChanged.addListener((changes, areaName) => {
+ browser.test.assertEq(
+ expectedAreaName,
+ areaName,
+ "Expected area name received by listener"
+ );
+ gResolve(changes);
+ });
+
+ async function checkChanges(areaName, changes, message) {
+ function checkSub(obj1, obj2) {
+ for (let prop in obj1) {
+ browser.test.assertTrue(
+ obj1[prop] !== undefined,
+ `checkChanges ${areaName} ${prop} is missing (${message})`
+ );
+ browser.test.assertTrue(
+ obj2[prop] !== undefined,
+ `checkChanges ${areaName} ${prop} is missing (${message})`
+ );
+ browser.test.assertEq(
+ obj1[prop].oldValue,
+ obj2[prop].oldValue,
+ `checkChanges ${areaName} ${prop} old (${message})`
+ );
+ browser.test.assertEq(
+ obj1[prop].newValue,
+ obj2[prop].newValue,
+ `checkChanges ${areaName} ${prop} new (${message})`
+ );
+ }
+ }
+
+ const recentChanges = await globalChanges;
+ checkSub(changes, recentChanges);
+ checkSub(recentChanges, changes);
+ clearGlobalChanges();
+ }
+
+ /* eslint-disable dot-notation */
+ async function runTests(areaName) {
+ expectedAreaName = areaName;
+ let storage = browser.storage[areaName];
+ // Set some data and then test getters.
+ try {
+ await storage.set({ "test-prop1": "value1", "test-prop2": "value2" });
+ await checkChanges(
+ areaName,
+ {
+ "test-prop1": { newValue: "value1" },
+ "test-prop2": { newValue: "value2" },
+ },
+ "set (a)"
+ );
+
+ await checkGet(areaName, "test-prop1", "value1");
+ await checkGet(areaName, "test-prop2", "value2");
+
+ let data = await storage.get({
+ "test-prop1": undefined,
+ "test-prop2": undefined,
+ other: "default",
+ });
+ browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (a)");
+ browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (a)");
+ browser.test.assertEq("default", data["other"], "other correct");
+
+ data = await storage.get(["test-prop1", "test-prop2", "other"]);
+ browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (b)");
+ browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (b)");
+ browser.test.assertFalse("other" in data, "other correct");
+
+ // Remove data in various ways.
+ await storage.remove("test-prop1");
+ await checkChanges(
+ areaName,
+ { "test-prop1": { oldValue: "value1" } },
+ "remove string"
+ );
+
+ data = await storage.get(["test-prop1", "test-prop2"]);
+ browser.test.assertFalse(
+ "test-prop1" in data,
+ "prop1 absent (remove string)"
+ );
+ browser.test.assertTrue(
+ "test-prop2" in data,
+ "prop2 present (remove string)"
+ );
+
+ await storage.set({ "test-prop1": "value1" });
+ await checkChanges(
+ areaName,
+ { "test-prop1": { newValue: "value1" } },
+ "set (c)"
+ );
+
+ data = await storage.get(["test-prop1", "test-prop2"]);
+ browser.test.assertEq(data["test-prop1"], "value1", "prop1 correct (c)");
+ browser.test.assertEq(data["test-prop2"], "value2", "prop2 correct (c)");
+
+ await storage.remove(["test-prop1", "test-prop2"]);
+ await checkChanges(
+ areaName,
+ {
+ "test-prop1": { oldValue: "value1" },
+ "test-prop2": { oldValue: "value2" },
+ },
+ "remove array"
+ );
+
+ data = await storage.get(["test-prop1", "test-prop2"]);
+ browser.test.assertFalse(
+ "test-prop1" in data,
+ "prop1 absent (remove array)"
+ );
+ browser.test.assertFalse(
+ "test-prop2" in data,
+ "prop2 absent (remove array)"
+ );
+
+ // test storage.clear
+ await storage.set({ "test-prop1": "value1", "test-prop2": "value2" });
+ // Make sure that set() handler happened before we clear the
+ // promise again.
+ await globalChanges;
+
+ clearGlobalChanges();
+ await storage.clear();
+
+ await checkChanges(
+ areaName,
+ {
+ "test-prop1": { oldValue: "value1" },
+ "test-prop2": { oldValue: "value2" },
+ },
+ "clear"
+ );
+ data = await storage.get(["test-prop1", "test-prop2"]);
+ browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)");
+ browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)");
+
+ // Make sure we can store complex JSON data.
+ // known previous values
+ await storage.set({ "test-prop1": "value1", "test-prop2": "value2" });
+
+ // Make sure the set() handler landed.
+ await globalChanges;
+
+ let date = new Date(0);
+
+ clearGlobalChanges();
+ await storage.set({
+ "test-prop1": {
+ str: "hello",
+ bool: true,
+ null: null,
+ undef: undefined,
+ obj: {},
+ arr: [1, 2],
+ date: new Date(0),
+ regexp: /regexp/,
+ },
+ });
+
+ await browser.test.assertRejects(
+ storage.set({
+ window,
+ }),
+ /DataCloneError|cyclic object value/
+ );
+
+ await browser.test.assertRejects(
+ storage.set({ "test-prop2": function func() {} }),
+ /DataCloneError/
+ );
+
+ const recentChanges = await globalChanges;
+
+ browser.test.assertEq(
+ "value1",
+ recentChanges["test-prop1"].oldValue,
+ "oldValue correct"
+ );
+ browser.test.assertEq(
+ "object",
+ typeof recentChanges["test-prop1"].newValue,
+ "newValue is obj"
+ );
+ clearGlobalChanges();
+
+ data = await storage.get({
+ "test-prop1": undefined,
+ "test-prop2": undefined,
+ });
+ let obj = data["test-prop1"];
+
+ if (areaName === "local") {
+ browser.test.assertEq(
+ String(date),
+ String(obj.date),
+ "date part correct"
+ );
+ browser.test.assertEq(
+ "/regexp/",
+ obj.regexp.toString(),
+ "regexp part correct"
+ );
+ } else {
+ browser.test.assertEq(
+ "1970-01-01T00:00:00.000Z",
+ String(obj.date),
+ "date part correct"
+ );
+
+ browser.test.assertEq(
+ "object",
+ typeof obj.regexp,
+ "regexp part is an object"
+ );
+ browser.test.assertEq(
+ 0,
+ Object.keys(obj.regexp).length,
+ "regexp part is an empty object"
+ );
+ }
+
+ browser.test.assertEq("hello", obj.str, "string part correct");
+ browser.test.assertEq(true, obj.bool, "bool part correct");
+ browser.test.assertEq(null, obj.null, "null part correct");
+ browser.test.assertEq(undefined, obj.undef, "undefined part correct");
+ browser.test.assertEq(undefined, obj.window, "window part correct");
+ browser.test.assertEq("object", typeof obj.obj, "object part correct");
+ browser.test.assertTrue(Array.isArray(obj.arr), "array part present");
+ browser.test.assertEq(1, obj.arr[0], "arr[0] part correct");
+ browser.test.assertEq(2, obj.arr[1], "arr[1] part correct");
+ browser.test.assertEq(2, obj.arr.length, "arr.length part correct");
+ } catch (e) {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ browser.test.notifyFail("storage");
+ }
+ }
+
+ browser.test.onMessage.addListener(msg => {
+ let promise;
+ if (msg === "test-local") {
+ promise = runTests("local");
+ } else if (msg === "test-sync") {
+ promise = runTests("sync");
+ } else if (msg === "test-session") {
+ promise = runTests("session");
+ }
+ promise.then(() => browser.test.sendMessage("test-finished"));
+ });
+
+ browser.test.sendMessage("ready");
+}
+
+async function test_contentscript_storage(storageType) {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+
+ permissions: ["storage"],
+ },
+
+ files: {
+ "content_script.js": `(${testStorageContentScript})(${checkGetImpl})`,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ extension.sendMessage(`test-${storageType}`);
+ await extension.awaitMessage("test-finished");
+
+ await extension.unload();
+ await contentPage.close();
+}
+
+async function test_storage_empty_events(areaName) {
+ async function background(areaName) {
+ let eventCount = 0;
+
+ browser.storage[areaName].onChanged.addListener(changes => {
+ browser.test.sendMessage("onChanged", [++eventCount, changes]);
+ });
+
+ browser.test.onMessage.addListener(async (method, arg) => {
+ let result = await browser.storage[areaName][method](arg);
+ browser.test.sendMessage("result", result);
+ });
+
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { permissions: ["storage"] },
+ background: `(${background})("${areaName}")`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ async function callStorageMethod(method, arg) {
+ info(`call storage.${areaName}.${method}(${JSON.stringify(arg) ?? ""})`);
+ extension.sendMessage(method, arg);
+ await extension.awaitMessage("result");
+ }
+
+ async function expectEvent(expectCount, expectChanges) {
+ equal(
+ JSON.stringify([expectCount, expectChanges]),
+ JSON.stringify(await extension.awaitMessage("onChanged")),
+ "Correct onChanged events count and data in the last changes notified."
+ );
+ }
+
+ await callStorageMethod("set", { alpha: 1 });
+ await expectEvent(1, { alpha: { newValue: 1 } });
+
+ await callStorageMethod("set", {});
+ // Setting nothing doesn't trigger onChanged event.
+
+ await callStorageMethod("set", { beta: 12 });
+ await expectEvent(2, { beta: { newValue: 12 } });
+
+ await callStorageMethod("remove", "alpha");
+ await expectEvent(3, { alpha: { oldValue: 1 } });
+
+ await callStorageMethod("remove", "alpha");
+ // Trying to remove alpha again doesn't trigger onChanged.
+
+ await callStorageMethod("clear");
+ await expectEvent(4, { beta: { oldValue: 12 } });
+
+ await callStorageMethod("clear");
+ // Clear again wothout onChanged. Test will fail on unexpected event/message.
+
+ await extension.unload();
+}
+
+async function test_storage_change_event_page(areaName) {
+ async function testOnChanged(targetIsStorageArea) {
+ function backgroundTestStorageTopNamespace(areaName) {
+ browser.storage.onChanged.addListener((changes, area) => {
+ browser.test.assertEq(area, areaName, "Expected areaName");
+ browser.test.assertEq(
+ JSON.stringify(changes),
+ `{"storageKey":{"newValue":"newStorageValue"}}`,
+ "Expected changes"
+ );
+ browser.test.sendMessage("onChanged_was_fired");
+ });
+ }
+ function backgroundTestStorageAreaNamespace(areaName) {
+ browser.storage[areaName].onChanged.addListener((changes, ...args) => {
+ browser.test.assertEq(args.length, 0, "no more args after changes");
+ browser.test.assertEq(
+ JSON.stringify(changes),
+ `{"storageKey":{"newValue":"newStorageValue"}}`,
+ `Expected changes via ${areaName}.onChanged event`
+ );
+ browser.test.sendMessage("onChanged_was_fired");
+ });
+ }
+ let background, onChangedName;
+ if (targetIsStorageArea) {
+ // Test storage.local.onChanged / storage.sync.onChanged.
+ background = backgroundTestStorageAreaNamespace;
+ onChangedName = `${areaName}.onChanged`;
+ } else {
+ background = backgroundTestStorageTopNamespace;
+ onChangedName = "onChanged";
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ background: { persistent: false },
+ },
+ background: `(${background})("${areaName}")`,
+ files: {
+ "trigger-change.html": `
+ <!DOCTYPE html><meta charset="utf-8">
+ <script src="trigger-change.js"></script>
+ `,
+ "trigger-change.js": async () => {
+ let areaName = location.search.slice(1);
+ await browser.storage[areaName].set({
+ storageKey: "newStorageValue",
+ });
+ browser.test.sendMessage("tried_to_trigger_change");
+ },
+ },
+ });
+ await extension.startup();
+ assertPersistentListeners(extension, "storage", onChangedName, {
+ primed: false,
+ });
+
+ await extension.terminateBackground();
+ assertPersistentListeners(extension, "storage", onChangedName, {
+ primed: true,
+ });
+
+ // Now trigger the event
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/trigger-change.html?${areaName}`
+ );
+ await extension.awaitMessage("tried_to_trigger_change");
+ await contentPage.close();
+ await extension.awaitMessage("onChanged_was_fired");
+
+ assertPersistentListeners(extension, "storage", onChangedName, {
+ primed: false,
+ });
+ await extension.unload();
+ }
+
+ async function testFn() {
+ // Test browser.storage.onChanged.addListener
+ await testOnChanged(/* targetIsStorageArea */ false);
+ // Test browser.storage.local.onChanged.addListener
+ // and browser.storage.sync.onChanged.addListener, depending on areaName.
+ await testOnChanged(/* targetIsStorageArea */ true);
+ }
+
+ return runWithPrefs([["extensions.eventPages.enabled", true]], testFn);
+}
diff --git a/toolkit/components/extensions/test/xpcshell/head_sync.js b/toolkit/components/extensions/test/xpcshell/head_sync.js
new file mode 100644
index 0000000000..7c88e23a86
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_sync.js
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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";
+
+/* exported withSyncContext */
+
+const { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+
+class KintoExtContext extends ExtensionCommon.BaseContext {
+ constructor(principal) {
+ let fakeExtension = { id: "test@web.extension", manifestVersion: 2 };
+ super("addon_parent", fakeExtension);
+ Object.defineProperty(this, "principal", {
+ value: principal,
+ configurable: true,
+ });
+ this.sandbox = Cu.Sandbox(principal, { wantXrays: false });
+ }
+
+ get cloneScope() {
+ return this.sandbox;
+ }
+}
+
+/**
+ * Call the given function with a newly-constructed context.
+ * Unload the context on the way out.
+ *
+ * @param {Function} f the function to call
+ */
+async function withContext(f) {
+ const ssm = Services.scriptSecurityManager;
+ const PRINCIPAL1 = ssm.createContentPrincipalFromOrigin(
+ "http://www.example.org"
+ );
+ const context = new KintoExtContext(PRINCIPAL1);
+ try {
+ await f(context);
+ } finally {
+ await context.unload();
+ }
+}
+
+/**
+ * Like withContext(), but also turn on the "storage.sync" pref for
+ * the duration of the function.
+ * Calls to this function can be replaced with calls to withContext
+ * once the pref becomes on by default.
+ *
+ * @param {Function} f the function to call
+ */
+async function withSyncContext(f) {
+ const STORAGE_SYNC_PREF = "webextensions.storage.sync.enabled";
+ let prefs = Services.prefs;
+
+ try {
+ prefs.setBoolPref(STORAGE_SYNC_PREF, true);
+ await withContext(f);
+ } finally {
+ prefs.clearUserPref(STORAGE_SYNC_PREF);
+ }
+}
diff --git a/toolkit/components/extensions/test/xpcshell/head_telemetry.js b/toolkit/components/extensions/test/xpcshell/head_telemetry.js
new file mode 100644
index 0000000000..b3aa5e84a8
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_telemetry.js
@@ -0,0 +1,435 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* exported IS_ANDROID_BUILD, IS_OOP, valueSum, clearHistograms, getSnapshots, promiseTelemetryRecorded,
+ assertDNRTelemetryMetricsDefined, assertDNRTelemetryMetricsNoSamples, assertDNRTelemetryMetricsGetValueEq,
+ assertDNRTelemetryMetricsSamplesCount, resetTelemetryData, setupTelemetryForTests */
+
+ChromeUtils.defineESModuleGetters(this, {
+ ContentTaskUtils: "resource://testing-common/ContentTaskUtils.sys.mjs",
+});
+
+// Allows to run xpcshell telemetry test also on products (e.g. Thunderbird) where
+// that telemetry wouldn't be actually collected in practice (but to be sure
+// that it will work on those products as well by just adding the product in
+// the telemetry metric definitions if it turns out we want to).
+Services.prefs.setBoolPref(
+ "toolkit.telemetry.testing.overrideProductsCheck",
+ true
+);
+
+const IS_OOP = Services.prefs.getBoolPref("extensions.webextensions.remote");
+
+// Initializing and asserting the expected telemetry is currently conditioned
+// on this const.
+// TODO(Bug 1752139) remove this along with initializing and asserting the expected
+// telemetry also for android build, once `Services.fog.testResetFOG()` is implemented
+// for Android builds.
+const IS_ANDROID_BUILD = AppConstants.platform === "android";
+
+const WEBEXT_EVENTPAGE_RUNNING_TIME_MS = "WEBEXT_EVENTPAGE_RUNNING_TIME_MS";
+const WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID =
+ "WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID";
+const WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT = "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT";
+const WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID =
+ "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID";
+
+// Keep this in sync with the order in Histograms.json for "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT":
+// the position of the category string determines the index of the values collected in the categorial
+// histogram and so the existing labels should be kept in the exact same order and any new category
+// to be added in the future should be appended to the existing ones.
+const HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES = [
+ "suspend",
+ "reset_other",
+ "reset_event",
+ "reset_listeners",
+ "reset_nativeapp",
+ "reset_streamfilter",
+];
+
+function valueSum(arr) {
+ return Object.values(arr).reduce((a, b) => a + b, 0);
+}
+
+function clearHistograms() {
+ Services.telemetry.getSnapshotForHistograms("main", true /* clear */);
+ Services.telemetry.getSnapshotForKeyedHistograms("main", true /* clear */);
+}
+
+function clearScalars() {
+ Services.telemetry.getSnapshotForScalars("main", true /* clear */);
+ Services.telemetry.getSnapshotForKeyedScalars("main", true /* clear */);
+}
+
+function getSnapshots(process) {
+ return Services.telemetry.getSnapshotForHistograms("main", false /* clear */)[
+ process
+ ];
+}
+
+function getKeyedSnapshots(process) {
+ return Services.telemetry.getSnapshotForKeyedHistograms(
+ "main",
+ false /* clear */
+ )[process];
+}
+
+// TODO Bug 1357509: There is no good way to make sure that the parent received
+// the histogram entries from the extension and content processes. Let's stick
+// to the ugly, spinning the event loop until we have a good approach.
+function promiseTelemetryRecorded(id, process, expectedCount) {
+ let condition = () => {
+ let snapshot = Services.telemetry.getSnapshotForHistograms(
+ "main",
+ false /* clear */
+ )[process][id];
+ return snapshot && valueSum(snapshot.values) >= expectedCount;
+ };
+ return ContentTaskUtils.waitForCondition(condition);
+}
+
+function promiseKeyedTelemetryRecorded(
+ id,
+ process,
+ expectedKey,
+ expectedCount
+) {
+ let condition = () => {
+ let snapshot = Services.telemetry.getSnapshotForKeyedHistograms(
+ "main",
+ false /* clear */
+ )[process][id];
+ return (
+ snapshot &&
+ snapshot[expectedKey] &&
+ valueSum(snapshot[expectedKey].values) >= expectedCount
+ );
+ };
+ return ContentTaskUtils.waitForCondition(condition);
+}
+
+function assertHistogramSnapshot(
+ histogramId,
+ { keyed, processSnapshot, expectedValue },
+ msg
+) {
+ let histogram;
+
+ if (keyed) {
+ histogram = Services.telemetry.getKeyedHistogramById(histogramId);
+ } else {
+ histogram = Services.telemetry.getHistogramById(histogramId);
+ }
+
+ let res = processSnapshot(histogram.snapshot());
+ Assert.deepEqual(res, expectedValue, msg);
+ return res;
+}
+
+function assertHistogramEmpty(histogramId) {
+ assertHistogramSnapshot(
+ histogramId,
+ {
+ processSnapshot: snapshot => snapshot.sum,
+ expectedValue: 0,
+ },
+ `No data recorded for histogram: ${histogramId}.`
+ );
+}
+
+function assertKeyedHistogramEmpty(histogramId) {
+ assertHistogramSnapshot(
+ histogramId,
+ {
+ keyed: true,
+ processSnapshot: snapshot => Object.keys(snapshot).length,
+ expectedValue: 0,
+ },
+ `No data recorded for histogram: ${histogramId}.`
+ );
+}
+
+function assertHistogramCategoryNotEmpty(
+ histogramId,
+ { category, categories, keyed, key },
+ msg
+) {
+ let message = msg;
+
+ if (!msg) {
+ message = `Data recorded for histogram: ${histogramId}, category "${category}"`;
+ if (keyed) {
+ message += `, key "${key}"`;
+ }
+ }
+
+ assertHistogramSnapshot(
+ histogramId,
+ {
+ keyed,
+ processSnapshot: snapshot => {
+ const categoryIndex = categories.indexOf(category);
+ if (keyed) {
+ return {
+ [key]: snapshot[key]
+ ? snapshot[key].values[categoryIndex] > 0
+ : null,
+ };
+ }
+ return snapshot.values[categoryIndex] > 0;
+ },
+ expectedValue: keyed ? { [key]: true } : true,
+ },
+ message
+ );
+}
+
+function setupTelemetryForTests() {
+ // FOG needs a profile directory to put its data in.
+ do_get_profile();
+ // FOG needs to be initialized in order for data to flow.
+ Services.fog.initializeFOG();
+}
+
+function resetTelemetryData() {
+ if (IS_ANDROID_BUILD) {
+ info("Skip testResetFOG on android builds");
+ return;
+ }
+ Services.fog.testResetFOG();
+
+ // Clear histograms data recorded in the unified telemetry
+ // (needed to make sure we can keep asserting that the same
+ // amount of samples collected by Glean should also be found
+ // in the related mirrored unified telemetry probe after we
+ // have reset Glean metrics data using testResetFOG).
+ clearHistograms();
+ clearScalars();
+}
+
+function assertDNRTelemetryMetricsDefined(metrics) {
+ const metricsNotFound = metrics.filter(metricDetails => {
+ const { metric, label } = metricDetails;
+ if (!Glean.extensionsApisDnr[metric]) {
+ return true;
+ }
+ if (label) {
+ return !Glean.extensionsApisDnr[metric][label];
+ }
+ return false;
+ });
+ Assert.deepEqual(
+ metricsNotFound,
+ [],
+ `All expected extensionsApisDnr Glean metrics should be found`
+ );
+}
+
+function assertDNRTelemetryMirrored({
+ gleanMetric,
+ gleanLabel,
+ unifiedName,
+ unifiedType,
+}) {
+ assertDNRTelemetryMetricsDefined([
+ { metric: gleanMetric, label: gleanLabel },
+ ]);
+ const gleanData = gleanLabel
+ ? Glean.extensionsApisDnr[gleanMetric][gleanLabel].testGetValue()
+ : Glean.extensionsApisDnr[gleanMetric].testGetValue();
+
+ if (!unifiedName) {
+ Assert.ok(
+ false,
+ `Unexpected missing unifiedName parameter on call to assertDNRTelemetryMirrored`
+ );
+ return;
+ }
+
+ let unifiedData;
+
+ switch (unifiedType) {
+ case "histogram": {
+ let found = false;
+ try {
+ const histogram = Services.telemetry.getHistogramById(unifiedName);
+ found = !!histogram;
+ } catch (err) {
+ Cu.reportError(err);
+ }
+ Assert.ok(found, `Expect an histogram named ${unifiedName} to be found`);
+ unifiedData = Services.telemetry.getSnapshotForHistograms("main", false)
+ .parent[unifiedName];
+ break;
+ }
+ case "keyedScalar": {
+ const snapshot = Services.telemetry.getSnapshotForKeyedScalars(
+ "main",
+ false
+ );
+ if (unifiedName in (snapshot?.parent || {})) {
+ unifiedData = snapshot.parent[unifiedName][gleanLabel];
+ }
+ break;
+ }
+ case "scalar": {
+ const snapshot = Services.telemetry.getSnapshotForScalars("main", false);
+ if (unifiedName in (snapshot?.parent || {})) {
+ unifiedData = snapshot.parent[unifiedName];
+ }
+ break;
+ }
+ default:
+ Assert.ok(
+ false,
+ `Unexpected unifiedType ${unifiedType} on call to assertDNRTelemetryMirrored`
+ );
+ return;
+ }
+
+ if (gleanData == undefined) {
+ Assert.deepEqual(
+ unifiedData,
+ undefined,
+ `Expect mirrored unified telemetry ${unifiedType} ${unifiedName} has no samples as Glean ${gleanMetric}`
+ );
+ } else {
+ switch (unifiedType) {
+ case "histogram": {
+ Assert.deepEqual(
+ valueSum(unifiedData.values),
+ valueSum(gleanData.values),
+ `Expect mirrored unified telemetry ${unifiedType} ${unifiedName} has samples mirrored from Glean ${gleanMetric}`
+ );
+ break;
+ }
+ case "scalar":
+ case "keyedScalar": {
+ Assert.deepEqual(
+ unifiedData,
+ gleanData,
+ `Expect mirrored unified telemetry ${unifiedType} ${unifiedName} has samples mirrored from Glean ${gleanMetric}`
+ );
+ break;
+ }
+ }
+ }
+}
+
+function assertDNRTelemetryMetricsNoSamples(metrics, msg) {
+ assertDNRTelemetryMetricsDefined(metrics);
+ for (const metricDetails of metrics) {
+ const { metric, label } = metricDetails;
+ if (IS_ANDROID_BUILD) {
+ info(
+ `Skip assertions on collected samples for extensionsApisDnr.${metric} on android builds (${msg})`
+ );
+ return;
+ }
+ const gleanData = label
+ ? Glean.extensionsApisDnr[metric][label].testGetValue()
+ : Glean.extensionsApisDnr[metric].testGetValue();
+ Assert.deepEqual(
+ gleanData,
+ undefined,
+ `Expect no sample for Glean metric extensionApisDnr.${metric} (${msg}): ${gleanData}`
+ );
+
+ if (metricDetails.mirroredName) {
+ const { mirroredName, mirroredType } = metricDetails;
+ assertDNRTelemetryMirrored({
+ gleanMetric: metric,
+ gleanLabel: label,
+ unifiedName: mirroredName,
+ unifiedType: mirroredType,
+ });
+ }
+ }
+}
+
+function assertDNRTelemetryMetricsGetValueEq(metrics, msg) {
+ assertDNRTelemetryMetricsDefined(metrics);
+ for (const metricDetails of metrics) {
+ const { metric, label, expectedGetValue } = metricDetails;
+ if (IS_ANDROID_BUILD) {
+ info(
+ `Skip assertions on collected samples for extensionsApisDnr.${metric} on android builds`
+ );
+ return;
+ }
+ const gleanData = label
+ ? Glean.extensionsApisDnr[metric][label].testGetValue()
+ : Glean.extensionsApisDnr[metric].testGetValue();
+ Assert.deepEqual(
+ gleanData,
+ expectedGetValue,
+ `Got expected value set on Glean metric extensionApisDnr.${metric}${
+ label ? `.${label}` : ""
+ } (${msg})`
+ );
+
+ if (metricDetails.mirroredName) {
+ const { mirroredName, mirroredType } = metricDetails;
+ assertDNRTelemetryMirrored({
+ gleanMetric: metric,
+ gleanLabel: label,
+ unifiedName: mirroredName,
+ unifiedType: mirroredType,
+ });
+ }
+ }
+}
+
+function assertDNRTelemetryMetricsSamplesCount(metrics, msg) {
+ assertDNRTelemetryMetricsDefined(metrics);
+
+ // This assertion helpers doesn't currently handle labeled metrics,
+ // raise an explicit error to catch if one is included by mistake.
+ const labeledMetricsFound = metrics.filter(metric => !!metric.label);
+ if (labeledMetricsFound.length) {
+ throw new Error(
+ `Unexpected labeled metrics in call to assertDNRTelemetryMetricsSamplesCount: ${labeledMetricsFound}`
+ );
+ }
+
+ for (const metricDetails of metrics) {
+ const { metric, expectedSamplesCount } = metricDetails;
+ if (IS_ANDROID_BUILD) {
+ info(
+ `Skip assertions on collected samples for extensionsApisDnr.${metric} on android builds`
+ );
+ return;
+ }
+ const gleanData = Glean.extensionsApisDnr[metric].testGetValue();
+ Assert.notEqual(
+ gleanData,
+ undefined,
+ `Got some sample for Glean metric extensionApisDnr.${metric}: ${
+ gleanData && JSON.stringify(gleanData)
+ }`
+ );
+ Assert.equal(
+ valueSum(gleanData.values),
+ expectedSamplesCount,
+ `Got the expected number of samples for Glean metric extensionsApisDnr.${metric} (${msg})`
+ );
+ // Make sure we are accumulating meaningfull values in the sample,
+ // if we do have samples for the bucket "0" it likely means we have
+ // not been collecting the value correctly (e.g. typo in the property
+ // name being collected).
+ Assert.ok(
+ !gleanData.values["0"],
+ `No sample for Glean metric extensionsApisDnr.${metric} should be collected for the bucket "0"`
+ );
+
+ if (metricDetails.mirroredName) {
+ const { mirroredName, mirroredType } = metricDetails;
+ assertDNRTelemetryMirrored({
+ gleanMetric: metric,
+ unifiedName: mirroredName,
+ unifiedType: mirroredType,
+ });
+ }
+ }
+}
diff --git a/toolkit/components/extensions/test/xpcshell/native_messaging.ini b/toolkit/components/extensions/test/xpcshell/native_messaging.ini
new file mode 100644
index 0000000000..06702670bc
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/native_messaging.ini
@@ -0,0 +1,19 @@
+[DEFAULT]
+head = head.js head_e10s.js head_native_messaging.js head_telemetry.js
+tail =
+firefox-appdir = browser
+skip-if = appname == "thunderbird" || os == "android"
+subprocess = true
+support-files =
+ data/**
+tags = webextensions
+
+[test_ext_native_messaging.js]
+skip-if =
+ (os == "win" && processor == "aarch64") # bug 1530841
+ apple_silicon # bug 1729540
+run-sequentially = very high failure rate in parallel
+[test_ext_native_messaging_perf.js]
+skip-if = tsan # Unreasonably slow, bug 1612707
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[test_ext_native_messaging_unresponsive.js]
diff --git a/toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js b/toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js
new file mode 100644
index 0000000000..8e48684095
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js
@@ -0,0 +1,141 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { ExtensionShortcutKeyMap } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionShortcuts.sys.mjs"
+);
+
+add_task(function test_ExtensionShortcutKeymap() {
+ const shortcutsMap = new ExtensionShortcutKeyMap();
+
+ shortcutsMap.recordShortcut("Ctrl+Shift+1", "Addon1", "Command1");
+ shortcutsMap.recordShortcut("Ctrl+Shift+1", "Addon2", "Command2");
+ shortcutsMap.recordShortcut("Ctrl+Alt+2", "Addon2", "Command3");
+ // Empty shortcut not expected to be recorded, just ignored.
+ shortcutsMap.recordShortcut("", "Addon3", "Command4");
+
+ Assert.equal(
+ shortcutsMap.size,
+ 2,
+ "Got the expected number of shortcut entries"
+ );
+ Assert.deepEqual(
+ {
+ shortcutWithTwoExtensions: shortcutsMap.getFirstAddonName("Ctrl+Shift+1"),
+ shortcutWithOnlyOneExtension:
+ shortcutsMap.getFirstAddonName("Ctrl+Alt+2"),
+ shortcutWithNoExtension: shortcutsMap.getFirstAddonName(""),
+ },
+ {
+ shortcutWithTwoExtensions: "Addon1",
+ shortcutWithOnlyOneExtension: "Addon2",
+ shortcutWithNoExtension: null,
+ },
+ "Got the expected results from getFirstAddonName calls"
+ );
+
+ Assert.deepEqual(
+ {
+ shortcutWithTwoExtensions: shortcutsMap.has("Ctrl+Shift+1"),
+ shortcutWithOnlyOneExtension: shortcutsMap.has("Ctrl+Alt+2"),
+ shortcutWithNoExtension: shortcutsMap.has(""),
+ },
+ {
+ shortcutWithTwoExtensions: true,
+ shortcutWithOnlyOneExtension: true,
+ shortcutWithNoExtension: false,
+ },
+ "Got the expected results from `has` calls"
+ );
+
+ shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon1", "Command1");
+ Assert.equal(
+ shortcutsMap.has("Ctrl+Shift+1"),
+ true,
+ "Expect shortcut to already exist after removing one duplicate"
+ );
+ Assert.equal(
+ shortcutsMap.getFirstAddonName("Ctrl+Shift+1"),
+ "Addon2",
+ "Expect getFirstAddonName to return the remaining addon name"
+ );
+
+ shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon2", "Command2");
+ Assert.equal(
+ shortcutsMap.has("Ctrl+Shift+1"),
+ false,
+ "Expect shortcut to not exist anymore after removing last entry"
+ );
+ Assert.equal(shortcutsMap.size, 1, "Got only one shortcut as expected");
+
+ shortcutsMap.clear();
+ Assert.equal(
+ shortcutsMap.size,
+ 0,
+ "Got no shortcut as expected after clearing the map"
+ );
+});
+
+// This test verify that ExtensionShortcutKeyMap does catch duplicated
+// shortcut when the two modifiers strings are associated to the same
+// key (in particular on macOS where Ctrl and Command keys are both translated
+// in the same modifier in the keyboard shortcuts).
+add_task(function test_PlatformShortcutString() {
+ const shortcutsMap = new ExtensionShortcutKeyMap();
+
+ // Make the class instance behave like it would while running on macOS.
+ // (this is just for unit testing purpose, there is a separate integration
+ // test exercising this behavior in a real "Manage Extension Shortcut"
+ // about:addons view and only running on macOS, skipped on other platforms).
+ shortcutsMap._os = "mac";
+
+ shortcutsMap.recordShortcut("Ctrl+Shift+1", "Addon1", "MacCommand1");
+
+ Assert.deepEqual(
+ {
+ hasWithCtrl: shortcutsMap.has("Ctrl+Shift+1"),
+ hasWithCommand: shortcutsMap.has("Command+Shift+1"),
+ },
+ {
+ hasWithCtrl: true,
+ hasWithCommand: true,
+ },
+ "Got the expected results from `has` calls"
+ );
+
+ Assert.deepEqual(
+ {
+ nameWithCtrl: shortcutsMap.getFirstAddonName("Ctrl+Shift+1"),
+ nameWithCommand: shortcutsMap.getFirstAddonName("Command+Shift+1"),
+ },
+ {
+ nameWithCtrl: "Addon1",
+ nameWithCommand: "Addon1",
+ },
+ "Got the expected results from `getFirstAddonName` calls"
+ );
+
+ // Add a duplicate shortcut using Command instead of Ctrl and
+ // verify the expected behaviors.
+ shortcutsMap.recordShortcut("Command+Shift+1", "Addon2", "MacCommand2");
+ Assert.equal(shortcutsMap.size, 1, "Got still one shortcut as expected");
+ shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon1", "MacCommand1");
+ Assert.equal(shortcutsMap.size, 1, "Got still one shortcut as expected");
+ Assert.deepEqual(
+ {
+ nameWithCtrl: shortcutsMap.getFirstAddonName("Ctrl+Shift+1"),
+ nameWithCommand: shortcutsMap.getFirstAddonName("Command+Shift+1"),
+ },
+ {
+ nameWithCtrl: "Addon2",
+ nameWithCommand: "Addon2",
+ },
+ "Got the expected results from `getFirstAddonName` calls"
+ );
+
+ // Remove the entry added with a shortcut using "Command" by using the
+ // equivalent shortcut using Ctrl.
+ shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon2", "MacCommand2");
+ Assert.equal(shortcutsMap.size, 0, "Got no shortcut as expected");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js
new file mode 100644
index 0000000000..7cde68ee98
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js
@@ -0,0 +1,86 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// Import the rust-based and kinto-based implementations
+const { extensionStorageSync: rustImpl } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorageSync.sys.mjs"
+);
+const { extensionStorageSyncKinto: kintoImpl } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs"
+);
+
+Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false);
+
+add_task(async function test_sync_migration() {
+ // There's no good reason to perform this test via test extensions - we just
+ // call the underlying APIs directly.
+
+ // Set some stuff using the kinto-based impl.
+ let e1 = { id: "test@mozilla.com" };
+ let c1 = { extension: e1, callOnClose() {} };
+ await kintoImpl.set(e1, { foo: "bar" }, c1);
+
+ let e2 = { id: "test-2@mozilla.com" };
+ let c2 = { extension: e2, callOnClose() {} };
+ await kintoImpl.set(e2, { second: "2nd" }, c2);
+
+ let e3 = { id: "test-3@mozilla.com" };
+ let c3 = { extension: e3, callOnClose() {} };
+
+ // And all the data should be magically migrated.
+ Assert.deepEqual(await rustImpl.get(e1, "foo", c1), { foo: "bar" });
+ Assert.deepEqual(await rustImpl.get(e2, null, c2), { second: "2nd" });
+
+ // Sanity check we really are doing what we think we are - set a value in our
+ // new one, it should not be reflected by kinto.
+ await rustImpl.set(e3, { third: "3rd" }, c3);
+ Assert.deepEqual(await rustImpl.get(e3, null, c3), { third: "3rd" });
+ Assert.deepEqual(await kintoImpl.get(e3, null, c3), {});
+ // cleanup.
+ await kintoImpl.clear(e1, c1);
+ await kintoImpl.clear(e2, c2);
+ await kintoImpl.clear(e3, c3);
+ await rustImpl.clear(e1, c1);
+ await rustImpl.clear(e2, c2);
+ await rustImpl.clear(e3, c3);
+});
+
+// It would be great to have failure tests, but that seems impossible to have
+// in automated tests given the conditions under which we migrate - it would
+// basically require us to arrange for zero free disk space or to somehow
+// arrange for sqlite to see an io error. Specially crafted "corrupt"
+// sqlite files doesn't help because that file must not exist for us to even
+// attempt migration.
+//
+// But - what we can test is that if .migratedOk on the new impl ever goes to
+// false we delegate correctly.
+add_task(async function test_sync_migration_delgates() {
+ let e1 = { id: "test@mozilla.com" };
+ let c1 = { extension: e1, callOnClose() {} };
+ await kintoImpl.set(e1, { foo: "bar" }, c1);
+
+ // We think migration went OK - `get` shouldn't see kinto.
+ Assert.deepEqual(rustImpl.get(e1, null, c1), {});
+
+ info(
+ "Setting migration failure flag to ensure we delegate to kinto implementation"
+ );
+ rustImpl.migrationOk = false;
+ // get should now be seeing kinto.
+ Assert.deepEqual(await rustImpl.get(e1, null, c1), { foo: "bar" });
+ // check everything else delegates.
+
+ await rustImpl.set(e1, { foo: "foo" }, c1);
+ Assert.deepEqual(await kintoImpl.get(e1, null, c1), { foo: "foo" });
+
+ Assert.equal(await rustImpl.getBytesInUse(e1, null, c1), 8);
+
+ await rustImpl.remove(e1, "foo", c1);
+ Assert.deepEqual(await kintoImpl.get(e1, null, c1), {});
+
+ await rustImpl.set(e1, { foo: "foo" }, c1);
+ Assert.deepEqual(await kintoImpl.get(e1, null, c1), { foo: "foo" });
+ await rustImpl.clear(e1, c1);
+ Assert.deepEqual(await kintoImpl.get(e1, null, c1), {});
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js
new file mode 100644
index 0000000000..1a57d0870e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js
@@ -0,0 +1,602 @@
+/* -*- 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_MatchPattern_matches() {
+ function test(url, pattern, normalized = pattern, options = {}, explicit) {
+ let uri = Services.io.newURI(url);
+
+ pattern = Array.prototype.concat.call(pattern);
+ normalized = Array.prototype.concat.call(normalized);
+
+ let patterns = pattern.map(pat => new MatchPattern(pat, options));
+
+ let set = new MatchPatternSet(pattern, options);
+ let set2 = new MatchPatternSet(patterns, options);
+
+ deepEqual(
+ set2.patterns,
+ patterns,
+ "Patterns in set should equal the input patterns"
+ );
+
+ equal(
+ set.matches(uri, explicit),
+ set2.matches(uri, explicit),
+ "Single pattern and pattern set should return the same match"
+ );
+
+ for (let [i, pat] of patterns.entries()) {
+ equal(
+ pat.pattern,
+ normalized[i],
+ "Pattern property should contain correct normalized pattern value"
+ );
+ }
+
+ if (patterns.length == 1) {
+ equal(
+ patterns[0].matches(uri, explicit),
+ set.matches(uri, explicit),
+ "Single pattern and string set should return the same match"
+ );
+ }
+
+ return set.matches(uri, explicit);
+ }
+
+ function pass({ url, pattern, normalized, options, explicit }) {
+ ok(
+ test(url, pattern, normalized, options, explicit),
+ `Expected match: ${JSON.stringify(pattern)}, ${url}`
+ );
+ }
+
+ function fail({ url, pattern, normalized, options, explicit }) {
+ ok(
+ !test(url, pattern, normalized, options, explicit),
+ `Expected no match: ${JSON.stringify(pattern)}, ${url}`
+ );
+ }
+
+ function invalid({ pattern }) {
+ Assert.throws(
+ () => new MatchPattern(pattern),
+ /.*/,
+ `Invalid pattern '${pattern}' should throw`
+ );
+ Assert.throws(
+ () => new MatchPatternSet([pattern]),
+ /.*/,
+ `Invalid pattern '${pattern}' should throw`
+ );
+ }
+
+ // Invalid pattern.
+ invalid({ pattern: "" });
+
+ // Pattern must include trailing slash.
+ invalid({ pattern: "http://mozilla.org" });
+
+ // Protocol not allowed.
+ invalid({ pattern: "gopher://wuarchive.wustl.edu/" });
+
+ pass({ url: "http://mozilla.org", pattern: "http://mozilla.org/" });
+ pass({ url: "http://mozilla.org/", pattern: "http://mozilla.org/" });
+
+ pass({ url: "http://mozilla.org/", pattern: "*://mozilla.org/" });
+ pass({ url: "https://mozilla.org/", pattern: "*://mozilla.org/" });
+ fail({ url: "file://mozilla.org/", pattern: "*://mozilla.org/" });
+ fail({ url: "ftp://mozilla.org/", pattern: "*://mozilla.org/" });
+
+ fail({ url: "http://mozilla.com", pattern: "http://*mozilla.com*/" });
+ fail({ url: "http://mozilla.com", pattern: "http://mozilla.*/" });
+ invalid({ pattern: "http:/mozilla.com/" });
+
+ pass({ url: "http://google.com", pattern: "http://*.google.com/" });
+ pass({ url: "http://docs.google.com", pattern: "http://*.google.com/" });
+
+ pass({ url: "http://mozilla.org:8080", pattern: "http://mozilla.org/" });
+ pass({ url: "http://mozilla.org:8080", pattern: "*://mozilla.org/" });
+ fail({ url: "http://mozilla.org:8080", pattern: "http://mozilla.org:8080/" });
+
+ // Now try with * in the path.
+ pass({ url: "http://mozilla.org", pattern: "http://mozilla.org/*" });
+ pass({ url: "http://mozilla.org/", pattern: "http://mozilla.org/*" });
+
+ pass({ url: "http://mozilla.org/", pattern: "*://mozilla.org/*" });
+ pass({ url: "https://mozilla.org/", pattern: "*://mozilla.org/*" });
+ fail({ url: "file://mozilla.org/", pattern: "*://mozilla.org/*" });
+ fail({ url: "http://mozilla.com", pattern: "http://mozilla.*/*" });
+
+ pass({ url: "http://google.com", pattern: "http://*.google.com/*" });
+ pass({ url: "http://docs.google.com", pattern: "http://*.google.com/*" });
+
+ // Check path stuff.
+ fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/" });
+ pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*" });
+ pass({
+ url: "http://mozilla.com/abc/def",
+ pattern: "http://mozilla.com/a*f",
+ });
+ pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/a*" });
+ pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*f" });
+ fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*e" });
+ fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*c" });
+
+ invalid({ pattern: "http:///a.html" });
+ pass({ url: "file:///foo", pattern: "file:///foo*" });
+ pass({ url: "file:///foo/bar.html", pattern: "file:///foo*" });
+
+ pass({ url: "http://mozilla.org/a", pattern: "<all_urls>" });
+ pass({ url: "https://mozilla.org/a", pattern: "<all_urls>" });
+ pass({ url: "ftp://mozilla.org/a", pattern: "<all_urls>" });
+ pass({ url: "file:///a", pattern: "<all_urls>" });
+ fail({ url: "gopher://wuarchive.wustl.edu/a", pattern: "<all_urls>" });
+
+ // Multiple patterns.
+ pass({ url: "http://mozilla.org", pattern: ["http://mozilla.org/"] });
+ pass({
+ url: "http://mozilla.org",
+ pattern: ["http://mozilla.org/", "http://mozilla.com/"],
+ });
+ pass({
+ url: "http://mozilla.com",
+ pattern: ["http://mozilla.org/", "http://mozilla.com/"],
+ });
+ fail({
+ url: "http://mozilla.biz",
+ pattern: ["http://mozilla.org/", "http://mozilla.com/"],
+ });
+
+ // Match url with fragments.
+ pass({
+ url: "http://mozilla.org/base#some-fragment",
+ pattern: "http://mozilla.org/base",
+ });
+
+ // Match data:-URLs.
+ pass({ url: "data:text/plain,foo", pattern: ["data:text/plain,foo"] });
+ pass({ url: "data:text/plain,foo", pattern: ["data:text/plain,*"] });
+ pass({
+ url: "data:text/plain;charset=utf-8,foo",
+ pattern: ["data:text/plain;charset=utf-8,foo"],
+ });
+ fail({
+ url: "data:text/plain,foo",
+ pattern: ["data:text/plain;charset=utf-8,foo"],
+ });
+ fail({
+ url: "data:text/plain;charset=utf-8,foo",
+ pattern: ["data:text/plain,foo"],
+ });
+
+ // Privileged matchers:
+ invalid({ pattern: "about:foo" });
+ invalid({ pattern: "resource://foo/*" });
+
+ pass({
+ url: "about:foo",
+ pattern: ["about:foo", "about:foo*"],
+ options: { restrictSchemes: false },
+ });
+ pass({
+ url: "about:foo",
+ pattern: ["about:foo*"],
+ options: { restrictSchemes: false },
+ });
+ pass({
+ url: "about:foobar",
+ pattern: ["about:foo*"],
+ options: { restrictSchemes: false },
+ });
+
+ pass({
+ url: "resource://foo/bar",
+ pattern: ["resource://foo/bar"],
+ options: { restrictSchemes: false },
+ });
+ fail({
+ url: "resource://fog/bar",
+ pattern: ["resource://foo/bar"],
+ options: { restrictSchemes: false },
+ });
+ fail({
+ url: "about:foo",
+ pattern: ["about:meh"],
+ options: { restrictSchemes: false },
+ });
+
+ // Matchers for schemes without host should ignore ignorePath.
+ pass({
+ url: "about:reader?http://e.com/",
+ pattern: ["about:reader*"],
+ options: { ignorePath: true, restrictSchemes: false },
+ });
+ pass({ url: "data:,", pattern: ["data:,*"], options: { ignorePath: true } });
+
+ // Matchers for schems without host should still match even if the explicit (host) flag is set.
+ pass({
+ url: "about:reader?explicit",
+ pattern: ["about:reader*"],
+ options: { restrictSchemes: false },
+ explicit: true,
+ });
+ pass({
+ url: "about:reader?explicit",
+ pattern: ["about:reader?explicit"],
+ options: { restrictSchemes: false },
+ explicit: true,
+ });
+ pass({ url: "data:,explicit", pattern: ["data:,explicit"], explicit: true });
+ pass({ url: "data:,explicit", pattern: ["data:,*"], explicit: true });
+
+ // Matchers without "//" separator in the pattern.
+ pass({ url: "data:text/plain;charset=utf-8,foo", pattern: ["data:*"] });
+ pass({
+ url: "about:blank",
+ pattern: ["about:*"],
+ options: { restrictSchemes: false },
+ });
+ pass({
+ url: "view-source:https://example.com",
+ pattern: ["view-source:*"],
+ options: { restrictSchemes: false },
+ });
+ invalid({ pattern: ["chrome:*"], options: { restrictSchemes: false } });
+ invalid({ pattern: "http:*" });
+
+ // Matchers for unrecognized schemes.
+ invalid({ pattern: "unknown-scheme:*" });
+ pass({
+ url: "unknown-scheme:foo",
+ pattern: ["unknown-scheme:foo"],
+ options: { restrictSchemes: false },
+ });
+ pass({
+ url: "unknown-scheme:foo",
+ pattern: ["unknown-scheme:*"],
+ options: { restrictSchemes: false },
+ });
+ pass({
+ url: "unknown-scheme://foo",
+ pattern: ["unknown-scheme://foo"],
+ options: { restrictSchemes: false },
+ });
+ pass({
+ url: "unknown-scheme://foo",
+ pattern: ["unknown-scheme://*"],
+ options: { restrictSchemes: false },
+ });
+ pass({
+ url: "unknown-scheme://foo",
+ pattern: ["unknown-scheme:*"],
+ options: { restrictSchemes: false },
+ });
+ fail({
+ url: "unknown-scheme://foo",
+ pattern: ["unknown-scheme:foo"],
+ options: { restrictSchemes: false },
+ });
+ fail({
+ url: "unknown-scheme:foo",
+ pattern: ["unknown-scheme://foo"],
+ options: { restrictSchemes: false },
+ });
+ fail({
+ url: "unknown-scheme:foo",
+ pattern: ["unknown-scheme://*"],
+ options: { restrictSchemes: false },
+ });
+
+ // Matchers for IPv6
+ pass({ url: "http://[::1]/", pattern: ["http://[::1]/"] });
+ pass({
+ url: "http://[2a03:4000:6:310e:216:3eff:fe53:99b]/",
+ pattern: ["http://[2a03:4000:6:310e:216:3eff:fe53:99b]/"],
+ });
+ fail({
+ url: "http://[2:4:6:3:2:3:f:b]/",
+ pattern: ["http://[2a03:4000:6:310e:216:3eff:fe53:99b]/"],
+ });
+
+ // Before fixing Bug 1529230, the only way to match a specific IPv6 url is by droping the brackets in pattern,
+ // thus we keep this pattern valid for the sake of backward compatibility
+ pass({ url: "http://[::1]/", pattern: ["http://::1/"] });
+ pass({
+ url: "http://[2a03:4000:6:310e:216:3eff:fe53:99b]/",
+ pattern: ["http://2a03:4000:6:310e:216:3eff:fe53:99b/"],
+ });
+});
+
+add_task(async function test_MatchPattern_overlaps() {
+ function test(filter, hosts, optional) {
+ filter = Array.prototype.concat.call(filter);
+ hosts = Array.prototype.concat.call(hosts);
+ optional = Array.prototype.concat.call(optional);
+
+ const set = new MatchPatternSet([...hosts, ...optional]);
+ const pat = new MatchPatternSet(filter);
+ return set.overlapsAll(pat);
+ }
+
+ function pass({ filter = [], hosts = [], optional = [] }) {
+ ok(
+ test(filter, hosts, optional),
+ `Expected overlap: ${filter}, ${hosts} (${optional})`
+ );
+ }
+
+ function fail({ filter = [], hosts = [], optional = [] }) {
+ ok(
+ !test(filter, hosts, optional),
+ `Expected no overlap: ${filter}, ${hosts} (${optional})`
+ );
+ }
+
+ // Direct comparison.
+ pass({ hosts: "http://ab.cd/", filter: "http://ab.cd/" });
+ fail({ hosts: "http://ab.cd/", filter: "ftp://ab.cd/" });
+
+ // Wildcard protocol.
+ pass({ hosts: "*://ab.cd/", filter: "https://ab.cd/" });
+ fail({ hosts: "*://ab.cd/", filter: "ftp://ab.cd/" });
+
+ // Wildcard subdomain.
+ pass({ hosts: "http://*.ab.cd/", filter: "http://ab.cd/" });
+ pass({ hosts: "http://*.ab.cd/", filter: "http://www.ab.cd/" });
+ fail({ hosts: "http://*.ab.cd/", filter: "http://ab.cd.ef/" });
+ fail({ hosts: "http://*.ab.cd/", filter: "http://www.cd/" });
+
+ // Wildcard subsumed.
+ pass({ hosts: "http://*.ab.cd/", filter: "http://*.cd/" });
+ fail({ hosts: "http://*.cd/", filter: "http://*.xy/" });
+
+ // Subdomain vs substring.
+ fail({ hosts: "http://*.ab.cd/", filter: "http://fake-ab.cd/" });
+ fail({ hosts: "http://*.ab.cd/", filter: "http://*.fake-ab.cd/" });
+
+ // Wildcard domain.
+ pass({ hosts: "http://*/", filter: "http://ab.cd/" });
+ fail({ hosts: "http://*/", filter: "https://ab.cd/" });
+
+ // Wildcard wildcards.
+ pass({ hosts: "<all_urls>", filter: "ftp://ab.cd/" });
+ fail({ hosts: "<all_urls>" });
+
+ // Multiple hosts.
+ pass({ hosts: ["http://ab.cd/"], filter: ["http://ab.cd/"] });
+ pass({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.cd/" });
+ pass({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.xy/" });
+ fail({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.zz/" });
+
+ // Multiple Multiples.
+ pass({
+ hosts: ["http://*.ab.cd/"],
+ filter: ["http://ab.cd/", "http://www.ab.cd/"],
+ });
+ pass({
+ hosts: ["http://ab.cd/", "http://ab.xy/"],
+ filter: ["http://ab.cd/", "http://ab.xy/"],
+ });
+ fail({
+ hosts: ["http://ab.cd/", "http://ab.xy/"],
+ filter: ["http://ab.cd/", "http://ab.zz/"],
+ });
+
+ // Optional.
+ pass({ hosts: [], optional: "http://ab.cd/", filter: "http://ab.cd/" });
+ pass({
+ hosts: "http://ab.cd/",
+ optional: "http://ab.xy/",
+ filter: ["http://ab.cd/", "http://ab.xy/"],
+ });
+ fail({
+ hosts: "http://ab.cd/",
+ optional: "https://ab.xy/",
+ filter: "http://ab.xy/",
+ });
+});
+
+add_task(async function test_MatchGlob() {
+ function test(url, pattern) {
+ let m = new MatchGlob(pattern[0]);
+ return m.matches(Services.io.newURI(url).spec);
+ }
+
+ function pass({ url, pattern }) {
+ ok(
+ test(url, pattern),
+ `Expected match: ${JSON.stringify(pattern)}, ${url}`
+ );
+ }
+
+ function fail({ url, pattern }) {
+ ok(
+ !test(url, pattern),
+ `Expected no match: ${JSON.stringify(pattern)}, ${url}`
+ );
+ }
+
+ let moz = "http://mozilla.org";
+
+ pass({ url: moz, pattern: ["*"] });
+ pass({ url: moz, pattern: ["http://*"] });
+ pass({ url: moz, pattern: ["*mozilla*"] });
+ // pass({url: moz, pattern: ["*example*", "*mozilla*"]});
+
+ pass({ url: moz, pattern: ["*://*"] });
+ pass({ url: "https://mozilla.org", pattern: ["*://*"] });
+
+ // Documentation example
+ pass({
+ url: "http://www.example.com/foo/bar",
+ pattern: ["http://???.example.com/foo/*"],
+ });
+ pass({
+ url: "http://the.example.com/foo/",
+ pattern: ["http://???.example.com/foo/*"],
+ });
+ fail({
+ url: "http://my.example.com/foo/bar",
+ pattern: ["http://???.example.com/foo/*"],
+ });
+ fail({
+ url: "http://example.com/foo/",
+ pattern: ["http://???.example.com/foo/*"],
+ });
+ fail({
+ url: "http://www.example.com/foo",
+ pattern: ["http://???.example.com/foo/*"],
+ });
+
+ // Matches path
+ let path = moz + "/abc/def";
+ pass({ url: path, pattern: ["*def"] });
+ pass({ url: path, pattern: ["*c/d*"] });
+ pass({ url: path, pattern: ["*org/abc*"] });
+ fail({ url: path + "/", pattern: ["*def"] });
+
+ // Trailing slash
+ pass({ url: moz, pattern: ["*.org/"] });
+ fail({ url: moz, pattern: ["*.org"] });
+
+ // Wrong TLD
+ fail({ url: moz, pattern: ["*oz*.com/"] });
+ // Case sensitive
+ fail({ url: moz, pattern: ["*.ORG/"] });
+});
+
+add_task(async function test_MatchGlob_redundant_wildcards_backtracking() {
+ const slow_build =
+ AppConstants.DEBUG || AppConstants.TSAN || AppConstants.ASAN;
+ const first_limit = slow_build ? 200 : 20;
+ {
+ // Bug 1570868 - repeated * in tabs.query glob causes too much backtracking.
+ let title = `Monster${"*".repeat(99)}Mash`;
+
+ // The first run could take longer than subsequent runs, as the DFA is lazily created.
+ let first_start = Date.now();
+ let glob = new MatchGlob(title);
+ let first_matches = glob.matches(title);
+ let first_duration = Date.now() - first_start;
+ ok(first_matches, `Expected match: ${title}, ${title}`);
+ ok(
+ first_duration < first_limit,
+ `First matching duration: ${first_duration}ms (limit: ${first_limit}ms)`
+ );
+
+ let start = Date.now();
+ let matches = glob.matches(title);
+ let duration = Date.now() - start;
+
+ ok(matches, `Expected match: ${title}, ${title}`);
+ ok(duration < 10, `Matching duration: ${duration}ms`);
+ }
+ {
+ // Similarly with any continuous combination of ?**???****? wildcards.
+ let title = `Monster${"?*".repeat(99)}Mash`;
+
+ // The first run could take longer than subsequent runs, as the DFA is lazily created.
+ let first_start = Date.now();
+ let glob = new MatchGlob(title);
+ let first_matches = glob.matches(title);
+ let first_duration = Date.now() - first_start;
+ ok(first_matches, `Expected match: ${title}, ${title}`);
+ ok(
+ first_duration < first_limit,
+ `First matching duration: ${first_duration}ms (limit: ${first_limit}ms)`
+ );
+
+ let start = Date.now();
+ let matches = glob.matches(title);
+ let duration = Date.now() - start;
+
+ ok(matches, `Expected match: ${title}, ${title}`);
+ ok(duration < 10, `Matching duration: ${duration}ms`);
+ }
+});
+
+add_task(async function test_MatchPattern_subsumes() {
+ function test(oldPat, newPat) {
+ let m = new MatchPatternSet(oldPat);
+ return m.subsumes(new MatchPattern(newPat));
+ }
+
+ function pass({ oldPat, newPat }) {
+ ok(test(oldPat, newPat), `${JSON.stringify(oldPat)} subsumes "${newPat}"`);
+ }
+
+ function fail({ oldPat, newPat }) {
+ ok(
+ !test(oldPat, newPat),
+ `${JSON.stringify(oldPat)} doesn't subsume "${newPat}"`
+ );
+ }
+
+ pass({ oldPat: ["<all_urls>"], newPat: "*://*/*" });
+ pass({ oldPat: ["<all_urls>"], newPat: "http://*/*" });
+ pass({ oldPat: ["<all_urls>"], newPat: "http://*.example.com/*" });
+
+ pass({ oldPat: ["*://*/*"], newPat: "http://*/*" });
+ pass({ oldPat: ["*://*/*"], newPat: "wss://*/*" });
+ pass({ oldPat: ["*://*/*"], newPat: "http://*.example.com/*" });
+
+ pass({ oldPat: ["*://*.example.com/*"], newPat: "http://*.example.com/*" });
+ pass({ oldPat: ["*://*.example.com/*"], newPat: "*://sub.example.com/*" });
+
+ pass({ oldPat: ["https://*/*"], newPat: "https://*.example.com/*" });
+ pass({
+ oldPat: ["http://*.example.com/*"],
+ newPat: "http://subdomain.example.com/*",
+ });
+ pass({
+ oldPat: ["http://*.sub.example.com/*"],
+ newPat: "http://sub.example.com/*",
+ });
+ pass({
+ oldPat: ["http://*.sub.example.com/*"],
+ newPat: "http://sec.sub.example.com/*",
+ });
+ pass({
+ oldPat: ["http://www.example.com/*"],
+ newPat: "http://www.example.com/path/*",
+ });
+ pass({
+ oldPat: ["http://www.example.com/path/*"],
+ newPat: "http://www.example.com/*",
+ });
+
+ fail({ oldPat: ["*://*/*"], newPat: "<all_urls>" });
+ fail({ oldPat: ["*://*/*"], newPat: "ftp://*/*" });
+ fail({ oldPat: ["*://*/*"], newPat: "file://*/*" });
+
+ fail({ oldPat: ["http://example.com/*"], newPat: "*://example.com/*" });
+ fail({ oldPat: ["http://example.com/*"], newPat: "https://example.com/*" });
+ fail({
+ oldPat: ["http://example.com/*"],
+ newPat: "http://otherexample.com/*",
+ });
+ fail({ oldPat: ["http://example.com/*"], newPat: "http://*.example.com/*" });
+ fail({
+ oldPat: ["http://example.com/*"],
+ newPat: "http://subdomain.example.com/*",
+ });
+
+ fail({
+ oldPat: ["http://subdomain.example.com/*"],
+ newPat: "http://example.com/*",
+ });
+ fail({
+ oldPat: ["http://subdomain.example.com/*"],
+ newPat: "http://*.example.com/*",
+ });
+ fail({
+ oldPat: ["http://sub.example.com/*"],
+ newPat: "http://*.sub.example.com/*",
+ });
+
+ fail({ oldPat: ["ws://example.com/*"], newPat: "wss://example.com/*" });
+ fail({ oldPat: ["http://example.com/*"], newPat: "ws://example.com/*" });
+ fail({ oldPat: ["https://example.com/*"], newPat: "wss://example.com/*" });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains.js b/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains.js
new file mode 100644
index 0000000000..e7f223f072
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains.js
@@ -0,0 +1,217 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+const ADDONS_RESTRICTED_DOMAINS_PREF =
+ "extensions.webextensions.addons-restricted-domains@mozilla.com.disabled";
+
+const DOMAINS = [
+ "addons-dev.allizom.org",
+ "mixed.badssl.com",
+ "careers.mozilla.com",
+ "developer.mozilla.org",
+ "test.example.com",
+];
+
+const CAN_ACCESS_ALL = DOMAINS.reduce((map, domain) => {
+ return { ...map, [domain]: true };
+}, {});
+
+function makePolicy(options) {
+ return new WebExtensionPolicy({
+ baseURL: "file:///foo/",
+ localizeCallback: str => str,
+ allowedOrigins: new MatchPatternSet(["<all_urls>"], { ignorePath: true }),
+ mozExtensionHostname: Services.uuid.generateUUID().toString().slice(1, -1),
+ ...options,
+ });
+}
+
+function makeCS(policy) {
+ return new WebExtensionContentScript(policy, {
+ matches: new MatchPatternSet(["<all_urls>"]),
+ });
+}
+
+function expectQuarantined(expectedDomains) {
+ for (let domain of DOMAINS) {
+ let uri = Services.io.newURI(`https://${domain}/`);
+ let quarantined = expectedDomains.includes(domain);
+
+ equal(
+ quarantined,
+ WebExtensionPolicy.isQuarantinedURI(uri),
+ `Expect ${uri.spec} to ${quarantined ? "" : "not"} be quarantined.`
+ );
+ }
+}
+
+function expectAccess(policy, cs, expected) {
+ for (let domain of DOMAINS) {
+ let uri = Services.io.newURI(`https://${domain}/`);
+ let access = expected[domain];
+ let match = access;
+
+ equal(
+ access,
+ !policy.quarantinedFromURI(uri),
+ `${policy.id} is ${access ? "not" : ""} quarantined from ${uri.spec}.`
+ );
+ equal(
+ access,
+ policy.canAccessURI(uri),
+ `Expect ${policy.id} ${access ? "can" : "can't"} access ${uri.spec}.`
+ );
+
+ equal(
+ match,
+ cs.matchesURI(uri),
+ `Expect ${cs.extension.id} to ${match ? "" : "not"} match ${uri.spec}.`
+ );
+ }
+}
+
+function expectHost(desc, host, quarantined) {
+ let uri = Services.io.newURI(`https://${host}/`);
+ equal(
+ quarantined,
+ WebExtensionPolicy.isQuarantinedURI(uri),
+ `Expect ${desc} "${host}" to ${quarantined ? "" : "not"} be quarantined.`
+ );
+}
+
+function makePolicies() {
+ const plain = makePolicy({ id: "plain@test" });
+ const system = makePolicy({ id: "system@test", isPrivileged: true });
+ const exempt = makePolicy({ id: "exempt@test", ignoreQuarantine: true });
+
+ return { plain, system, exempt };
+}
+
+function makeContentScripts(policies) {
+ return policies.map(makeCS);
+}
+
+add_task(async function test_QuarantinedDomains() {
+ const { plain, system, exempt } = makePolicies();
+ const [plainCS, systemCS, exemptCS] = makeContentScripts([
+ plain,
+ system,
+ exempt,
+ ]);
+
+ info("Initial pref state is an empty list.");
+ expectQuarantined([]);
+
+ expectAccess(plain, plainCS, CAN_ACCESS_ALL);
+ expectAccess(system, systemCS, CAN_ACCESS_ALL);
+ expectAccess(exempt, exemptCS, CAN_ACCESS_ALL);
+
+ info("Default test domain list.");
+ Services.prefs.setStringPref(
+ "extensions.quarantinedDomains.list",
+ "addons-dev.allizom.org,mixed.badssl.com,test.example.com"
+ );
+
+ expectQuarantined([
+ "addons-dev.allizom.org",
+ "mixed.badssl.com",
+ "test.example.com",
+ ]);
+
+ expectAccess(plain, plainCS, {
+ "addons-dev.allizom.org": false,
+ "mixed.badssl.com": false,
+ "careers.mozilla.com": true,
+ "developer.mozilla.org": true,
+ "test.example.com": false,
+ });
+
+ expectAccess(system, systemCS, CAN_ACCESS_ALL);
+ expectAccess(exempt, exemptCS, CAN_ACCESS_ALL);
+
+ info("Disable the Quarantined Domains feature.");
+ Services.prefs.setBoolPref("extensions.quarantinedDomains.enabled", false);
+ expectQuarantined([]);
+
+ expectAccess(plain, plainCS, CAN_ACCESS_ALL);
+ expectAccess(system, systemCS, CAN_ACCESS_ALL);
+ expectAccess(exempt, exemptCS, CAN_ACCESS_ALL);
+
+ info(
+ "Enable again, drop addons-dev.allizom.org and add developer.mozilla.org to the pref."
+ );
+ Services.prefs.setBoolPref("extensions.quarantinedDomains.enabled", true);
+
+ Services.prefs.setStringPref(
+ "extensions.quarantinedDomains.list",
+ "mixed.badssl.com,developer.mozilla.org,test.example.com"
+ );
+ expectQuarantined([
+ "mixed.badssl.com",
+ "developer.mozilla.org",
+ "test.example.com",
+ ]);
+
+ expectAccess(plain, plainCS, {
+ "addons-dev.allizom.org": true,
+ "mixed.badssl.com": false,
+ "careers.mozilla.com": true,
+ "developer.mozilla.org": false,
+ "test.example.com": false,
+ });
+
+ expectAccess(system, systemCS, CAN_ACCESS_ALL);
+ expectAccess(exempt, exemptCS, CAN_ACCESS_ALL);
+
+ expectHost("host with a port", "test.example.com:1025", true);
+
+ expectHost("FQDN", "test.example.com.", false);
+ expectHost("subdomain", "subdomain.test.example.com", false);
+ expectHost("domain with prefix", "pretest.example.com", false);
+ expectHost("domain with suffix", "test.example.comsuf", false);
+});
+
+// Make sure we honor the system add-on pref.
+add_task(
+ {
+ pref_set: [
+ [ADDONS_RESTRICTED_DOMAINS_PREF, true],
+ [
+ "extensions.quarantinedDomains.list",
+ "addons-dev.allizom.org,mixed.badssl.com,test.example.com",
+ ],
+ ],
+ },
+ async function test_QuarantinedDomains_with_system_addon_disabled() {
+ await AddonTestUtils.promiseRestartManager();
+
+ const { plain, system, exempt } = makePolicies();
+ const [plainCS, systemCS, exemptCS] = makeContentScripts([
+ plain,
+ system,
+ exempt,
+ ]);
+
+ expectQuarantined([]);
+ expectAccess(plain, plainCS, CAN_ACCESS_ALL);
+ expectAccess(system, systemCS, CAN_ACCESS_ALL);
+ expectAccess(exempt, exemptCS, CAN_ACCESS_ALL);
+
+ // When the user changes this pref to re-enable the system add-on...
+ Services.prefs.setBoolPref(ADDONS_RESTRICTED_DOMAINS_PREF, false);
+ // ...after a AOM restart...
+ await AddonTestUtils.promiseRestartManager();
+ // ...we expect no change.
+ expectQuarantined([]);
+ expectAccess(plain, plainCS, CAN_ACCESS_ALL);
+ expectAccess(system, systemCS, CAN_ACCESS_ALL);
+ expectAccess(exempt, exemptCS, CAN_ACCESS_ALL);
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js b/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js
new file mode 100644
index 0000000000..ef55ed37e8
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js
@@ -0,0 +1,274 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const NS_ERROR_DOM_QUOTA_EXCEEDED_ERR = 0x80530016;
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "StorageSyncService",
+ "@mozilla.org/extensions/storage/sync;1",
+ "nsIInterfaceRequestor"
+);
+
+function promisify(func, ...params) {
+ return new Promise((resolve, reject) => {
+ let changes = [];
+ func(...params, {
+ QueryInterface: ChromeUtils.generateQI([
+ "mozIExtensionStorageListener",
+ "mozIExtensionStorageCallback",
+ "mozIBridgedSyncEngineCallback",
+ "mozIBridgedSyncEngineApplyCallback",
+ ]),
+ onChanged(extId, json) {
+ changes.push({ extId, changes: JSON.parse(json) });
+ },
+ handleSuccess(value) {
+ resolve({
+ changes,
+ value: typeof value == "string" ? JSON.parse(value) : value,
+ });
+ },
+ handleError(code, message) {
+ reject(Components.Exception(message, code));
+ },
+ });
+ });
+}
+
+add_task(async function setup_storage_sync() {
+ // So that we can write to the profile directory.
+ do_get_profile();
+});
+
+add_task(async function test_storage_sync_service() {
+ const service = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea);
+ {
+ let { changes, value } = await promisify(
+ service.set,
+ "ext-1",
+ JSON.stringify({
+ hi: "hello! 💖",
+ bye: "adiós",
+ })
+ );
+ deepEqual(
+ changes,
+ [
+ {
+ extId: "ext-1",
+ changes: {
+ hi: {
+ newValue: "hello! 💖",
+ },
+ bye: {
+ newValue: "adiós",
+ },
+ },
+ },
+ ],
+ "`set` should notify listeners about changes"
+ );
+ ok(!value, "`set` should not return a value");
+ }
+
+ {
+ let { changes, value } = await promisify(
+ service.get,
+ "ext-1",
+ JSON.stringify(["hi"])
+ );
+ deepEqual(changes, [], "`get` should not notify listeners");
+ deepEqual(
+ value,
+ {
+ hi: "hello! 💖",
+ },
+ "`get` with key should return value"
+ );
+
+ let { value: allValues } = await promisify(service.get, "ext-1", "null");
+ deepEqual(
+ allValues,
+ {
+ hi: "hello! 💖",
+ bye: "adiós",
+ },
+ "`get` without a key should return all values"
+ );
+ }
+
+ {
+ await promisify(
+ service.set,
+ "ext-2",
+ JSON.stringify({
+ hi: "hola! 👋",
+ })
+ );
+ await promisify(service.clear, "ext-1");
+ let { value: allValues } = await promisify(service.get, "ext-1", "null");
+ deepEqual(allValues, {}, "clear removed ext-1");
+
+ let { value: allValues2 } = await promisify(service.get, "ext-2", "null");
+ deepEqual(allValues2, { hi: "hola! 👋" }, "clear didn't remove ext-2");
+ // We need to clear data for ext-2 too, so later tests don't fail due to
+ // this data.
+ await promisify(service.clear, "ext-2");
+ }
+});
+
+add_task(async function test_storage_sync_bridged_engine() {
+ const area = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea);
+ const engine = StorageSyncService.getInterface(Ci.mozIBridgedSyncEngine);
+
+ info("Add some local items");
+ await promisify(area.set, "ext-1", JSON.stringify({ a: "abc" }));
+ await promisify(area.set, "ext-2", JSON.stringify({ b: "xyz" }));
+
+ info("Start a sync");
+ await promisify(engine.syncStarted);
+
+ info("Store some incoming synced items");
+ let incomingEnvelopesAsJSON = [
+ {
+ id: "guidAAA",
+ modified: 0.1,
+ payload: JSON.stringify({
+ extId: "ext-2",
+ data: JSON.stringify({
+ c: 1234,
+ }),
+ }),
+ },
+ {
+ id: "guidBBB",
+ modified: 0.1,
+ payload: JSON.stringify({
+ extId: "ext-3",
+ data: JSON.stringify({
+ d: "new! ✨",
+ }),
+ }),
+ },
+ ].map(e => JSON.stringify(e));
+ await promisify(area.storeIncoming, incomingEnvelopesAsJSON);
+
+ info("Merge");
+ // Three levels of JSON wrapping: each outgoing envelope, the cleartext in
+ // each envelope, and the extension storage data in each cleartext payload.
+ let { value: outgoingEnvelopesAsJSON } = await promisify(area.apply);
+ let outgoingEnvelopes = outgoingEnvelopesAsJSON.map(json => JSON.parse(json));
+ let parsedCleartexts = outgoingEnvelopes.map(e => JSON.parse(e.payload));
+ let parsedData = parsedCleartexts.map(c => JSON.parse(c.data));
+
+ let { changes } = await promisify(
+ area.QueryInterface(Ci.mozISyncedExtensionStorageArea)
+ .fetchPendingSyncChanges
+ );
+ deepEqual(
+ changes,
+ [
+ {
+ extId: "ext-2",
+ changes: {
+ c: { newValue: 1234 },
+ },
+ },
+ {
+ extId: "ext-3",
+ changes: {
+ d: { newValue: "new! ✨" },
+ },
+ },
+ ],
+ "Should return pending synced changes for observers"
+ );
+
+ // ext-1 doesn't exist remotely yet, so the Rust sync layer will generate
+ // a GUID for it. We don't know what it is, so we find it by the extension
+ // ID.
+ let ext1Index = parsedCleartexts.findIndex(c => c.extId == "ext-1");
+ greater(ext1Index, -1, "Should find envelope for ext-1");
+ let ext1Guid = outgoingEnvelopes[ext1Index].id;
+
+ // ext-2 has a remote GUID that we set in the test above.
+ let ext2Index = outgoingEnvelopes.findIndex(c => c.id == "guidAAA");
+ greater(ext2Index, -1, "Should find envelope for ext-2");
+
+ equal(outgoingEnvelopes.length, 2, "Should upload ext-1 and ext-2");
+ deepEqual(
+ parsedData[ext1Index],
+ {
+ a: "abc",
+ },
+ "Should upload new data for ext-1"
+ );
+ deepEqual(
+ parsedData[ext2Index],
+ {
+ b: "xyz",
+ c: 1234,
+ },
+ "Should merge local and remote data for ext-2"
+ );
+
+ info("Mark all extensions as uploaded");
+ await promisify(engine.setUploaded, 0, [ext1Guid, "guidAAA"]);
+
+ info("Finish sync");
+ await promisify(engine.syncFinished);
+
+ // Try fetching values for the remote-only extension we just synced.
+ let { value: ext3Value } = await promisify(area.get, "ext-3", "null");
+ deepEqual(
+ ext3Value,
+ {
+ d: "new! ✨",
+ },
+ "Should return new keys for ext-3"
+ );
+
+ info("Try applying a second time");
+ let secondApply = await promisify(area.apply);
+ deepEqual(secondApply.value, {}, "Shouldn't merge anything on second apply");
+
+ info("Wipe all items");
+ await promisify(engine.wipe);
+
+ for (let extId of ["ext-1", "ext-2", "ext-3"]) {
+ // `get` always returns an object, even if there are no keys for the
+ // extension ID.
+ let { value } = await promisify(area.get, extId, "null");
+ deepEqual(value, {}, `Wipe should remove all values for ${extId}`);
+ }
+});
+
+add_task(async function test_storage_sync_quota() {
+ const service = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea);
+ const engine = StorageSyncService.getInterface(Ci.mozIBridgedSyncEngine);
+ await promisify(engine.wipe);
+ await promisify(service.set, "ext-1", JSON.stringify({ x: "hi" }));
+ await promisify(service.set, "ext-1", JSON.stringify({ longer: "value" }));
+
+ let { value: v1 } = await promisify(service.getBytesInUse, "ext-1", '"x"');
+ Assert.equal(v1, 5); // key len without quotes, value len with quotes.
+ let { value: v2 } = await promisify(service.getBytesInUse, "ext-1", "null");
+ // 5 from 'x', plus 'longer' (6 for key, 7 for value = 13) = 18.
+ Assert.equal(v2, 18);
+
+ // Now set something greater than our quota.
+ await Assert.rejects(
+ promisify(
+ service.set,
+ "ext-1",
+ JSON.stringify({
+ big: "x".repeat(Ci.mozIExtensionStorageArea.SYNC_QUOTA_BYTES),
+ })
+ ),
+ ex => ex.result == NS_ERROR_DOM_QUOTA_EXCEEDED_ERR,
+ "should reject with NS_ERROR_DOM_QUOTA_EXCEEDED_ERR"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js
new file mode 100644
index 0000000000..fe05893f84
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js
@@ -0,0 +1,323 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { newURI } = Services.io;
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+async function test_url_matching({
+ manifestVersion = 2,
+ allowedOrigins = [],
+ checkPermissions,
+ expectMatches,
+}) {
+ let policy = new WebExtensionPolicy({
+ id: "foo@bar.baz",
+ mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2",
+ baseURL: "file:///foo",
+
+ manifestVersion,
+ allowedOrigins: new MatchPatternSet(allowedOrigins),
+ localizeCallback() {},
+ });
+
+ let contentScript = new WebExtensionContentScript(policy, {
+ checkPermissions,
+
+ matches: new MatchPatternSet(["http://*.foo.com/bar", "*://bar.com/baz/*"]),
+
+ excludeMatches: new MatchPatternSet(["*://bar.com/baz/quux"]),
+
+ includeGlobs: ["*flerg*", "*.com/bar", "*/quux"].map(
+ glob => new MatchGlob(glob)
+ ),
+
+ excludeGlobs: ["*glorg*"].map(glob => new MatchGlob(glob)),
+ });
+
+ equal(
+ expectMatches,
+ contentScript.matchesURI(newURI("http://www.foo.com/bar")),
+ `Simple matches include should ${expectMatches ? "" : "not "} match.`
+ );
+
+ equal(
+ expectMatches,
+ contentScript.matchesURI(newURI("https://bar.com/baz/xflergx")),
+ `Simple matches include should ${expectMatches ? "" : "not "} match.`
+ );
+
+ ok(
+ !contentScript.matchesURI(newURI("https://bar.com/baz/xx")),
+ "Failed includeGlobs match pattern should not match"
+ );
+
+ ok(
+ !contentScript.matchesURI(newURI("https://bar.com/baz/quux")),
+ "Excluded match pattern should not match"
+ );
+
+ ok(
+ !contentScript.matchesURI(newURI("https://bar.com/baz/xflergxglorgx")),
+ "Excluded match glob should not match"
+ );
+}
+
+add_task(function test_WebExtensionContentScript_urls_mv2() {
+ return test_url_matching({ manifestVersion: 2, expectMatches: true });
+});
+
+add_task(function test_WebExtensionContentScript_urls_mv2_checkPermissions() {
+ return test_url_matching({
+ manifestVersion: 2,
+ checkPermissions: true,
+ expectMatches: false,
+ });
+});
+
+add_task(function test_WebExtensionContentScript_urls_mv2_with_permissions() {
+ return test_url_matching({
+ manifestVersion: 2,
+ checkPermissions: true,
+ allowedOrigins: ["<all_urls>"],
+ expectMatches: true,
+ });
+});
+
+add_task(function test_WebExtensionContentScript_urls_mv3() {
+ // checkPermissions ignored here because it's forced for MV3.
+ return test_url_matching({
+ manifestVersion: 3,
+ checkPermissions: false,
+ expectMatches: false,
+ });
+});
+
+add_task(function test_WebExtensionContentScript_mv3_all_urls() {
+ return test_url_matching({
+ manifestVersion: 3,
+ allowedOrigins: ["<all_urls>"],
+ expectMatches: true,
+ });
+});
+
+add_task(function test_WebExtensionContentScript_mv3_wildcards() {
+ return test_url_matching({
+ manifestVersion: 3,
+ allowedOrigins: ["*://*.foo.com/*", "*://*.bar.com/*"],
+ expectMatches: true,
+ });
+});
+
+add_task(function test_WebExtensionContentScript_mv3_specific() {
+ return test_url_matching({
+ manifestVersion: 3,
+ allowedOrigins: ["http://www.foo.com/*", "https://bar.com/*"],
+ expectMatches: true,
+ });
+});
+
+add_task(function test_WebExtensionContentScript_restricted() {
+ let tests = [
+ {
+ manifestVersion: 2,
+ permissions: [],
+ expect: false,
+ },
+ {
+ manifestVersion: 2,
+ permissions: ["mozillaAddons"],
+ expect: true,
+ },
+ {
+ manifestVersion: 3,
+ permissions: [],
+ expect: false,
+ },
+ {
+ manifestVersion: 3,
+ permissions: ["mozillaAddons"],
+ expect: true,
+ },
+ ];
+
+ for (let { manifestVersion, permissions, expect } of tests) {
+ let policy = new WebExtensionPolicy({
+ id: "foo@bar.baz",
+ mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2",
+ baseURL: "file:///foo",
+
+ manifestVersion,
+ permissions,
+ allowedOrigins: new MatchPatternSet(["<all_urls>"]),
+ localizeCallback() {},
+ });
+ let contentScript = new WebExtensionContentScript(policy, {
+ checkPermissions: true,
+ matches: new MatchPatternSet(["<all_urls>"]),
+ });
+
+ // AMO is on the extensions.webextensions.restrictedDomains list.
+ equal(
+ expect,
+ contentScript.matchesURI(newURI("https://addons.mozilla.org/foo")),
+ `Expect extension with [${permissions}] to ${expect ? "" : "not"} match`
+ );
+ }
+});
+
+async function test_frame_matching(meta) {
+ if (AppConstants.platform == "linux") {
+ // The windowless browser currently does not load correctly on Linux on
+ // infra.
+ return;
+ }
+
+ let baseURL = `http://example.com/data`;
+ let urls = {
+ topLevel: `${baseURL}/file_toplevel.html`,
+ iframe: `${baseURL}/file_iframe.html`,
+ srcdoc: "about:srcdoc",
+ aboutBlank: "about:blank",
+ };
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(urls.topLevel);
+
+ let tests = [
+ {
+ matches: ["http://example.com/data/*"],
+ contentScript: {},
+ topLevel: true,
+ iframe: false,
+ aboutBlank: false,
+ srcdoc: false,
+ },
+
+ {
+ matches: ["http://example.com/data/*"],
+ contentScript: {
+ frameID: 0,
+ },
+ topLevel: true,
+ iframe: false,
+ aboutBlank: false,
+ srcdoc: false,
+ },
+
+ {
+ matches: ["http://example.com/data/*"],
+ contentScript: {
+ allFrames: true,
+ },
+ topLevel: true,
+ iframe: true,
+ aboutBlank: false,
+ srcdoc: false,
+ },
+
+ {
+ matches: ["http://example.com/data/*"],
+ contentScript: {
+ allFrames: true,
+ matchAboutBlank: true,
+ },
+ topLevel: true,
+ iframe: true,
+ aboutBlank: true,
+ srcdoc: true,
+ },
+
+ {
+ matches: ["http://foo.com/data/*"],
+ contentScript: {
+ allFrames: true,
+ matchAboutBlank: true,
+ },
+ topLevel: false,
+ iframe: false,
+ aboutBlank: false,
+ srcdoc: false,
+ },
+ ];
+
+ // matchesWindowGlobal tests against content frames
+ await contentPage.spawn([{ tests, urls, meta }], args => {
+ let { manifestVersion = 2, allowedOrigins = [], expectMatches } = args.meta;
+
+ this.windows = new Map();
+ this.windows.set(this.content.location.href, this.content);
+ for (let c of Array.from(this.content.frames)) {
+ this.windows.set(c.location.href, c);
+ }
+ const { MatchPatternSet, WebExtensionContentScript, WebExtensionPolicy } =
+ Cu.getGlobalForObject(Services);
+ this.policy = new WebExtensionPolicy({
+ id: "foo@bar.baz",
+ mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2",
+ baseURL: "file:///foo",
+
+ manifestVersion,
+ allowedOrigins: new MatchPatternSet(allowedOrigins),
+ localizeCallback() {},
+ });
+
+ let tests = args.tests.map(t => {
+ t.contentScript.matches = new MatchPatternSet(t.matches);
+ t.script = new WebExtensionContentScript(this.policy, t.contentScript);
+ return t;
+ });
+ for (let [i, test] of tests.entries()) {
+ for (let [frame, url] of Object.entries(args.urls)) {
+ let should = test[frame] ? "should" : "should not";
+ let wgc = this.windows.get(url).windowGlobalChild;
+ Assert.equal(
+ test.script.matchesWindowGlobal(wgc),
+ test[frame] && expectMatches,
+ `Script ${i} ${should} match the ${frame} frame`
+ );
+ }
+ }
+ });
+
+ await contentPage.close();
+}
+
+add_task(function test_WebExtensionContentScript_frames_mv2() {
+ return test_frame_matching({
+ manifestVersion: 2,
+ expectMatches: true,
+ });
+});
+
+add_task(function test_WebExtensionContentScript_frames_mv3() {
+ return test_frame_matching({
+ manifestVersion: 3,
+ expectMatches: false,
+ });
+});
+
+add_task(function test_WebExtensionContentScript_frames_mv3_all_urls() {
+ return test_frame_matching({
+ manifestVersion: 3,
+ allowedOrigins: ["<all_urls>"],
+ expectMatches: true,
+ });
+});
+
+add_task(function test_WebExtensionContentScript_frames_mv3_wildcards() {
+ return test_frame_matching({
+ manifestVersion: 3,
+ allowedOrigins: ["*://*.example.com/*"],
+ expectMatches: true,
+ });
+});
+
+add_task(function test_WebExtensionContentScript_frames_mv3_specific() {
+ return test_frame_matching({
+ manifestVersion: 3,
+ allowedOrigins: ["http://example.com/*"],
+ expectMatches: true,
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js
new file mode 100644
index 0000000000..ff2cc3c2ac
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js
@@ -0,0 +1,620 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { newURI } = Services.io;
+
+add_task(async function test_WebExtensionPolicy() {
+ const id = "foo@bar.baz";
+ const uuid = "ca9d3f23-125c-4b24-abfc-1ca2692b0610";
+
+ const baseURL = "file:///foo/";
+ const mozExtURL = `moz-extension://${uuid}/`;
+ const mozExtURI = newURI(mozExtURL);
+
+ let policy = new WebExtensionPolicy({
+ id,
+ mozExtensionHostname: uuid,
+ baseURL,
+
+ localizeCallback(str) {
+ return `<${str}>`;
+ },
+
+ allowedOrigins: new MatchPatternSet(["http://foo.bar/", "*://*.baz/"], {
+ ignorePath: true,
+ }),
+ permissions: ["<all_urls>"],
+ webAccessibleResources: [
+ {
+ resources: ["/foo/*", "/bar.baz"].map(glob => new MatchGlob(glob)),
+ },
+ ],
+ });
+
+ equal(policy.active, false, "Active attribute should initially be false");
+
+ // GetURL
+
+ equal(
+ policy.getURL(),
+ mozExtURL,
+ "getURL() should return the correct root URL"
+ );
+ equal(
+ policy.getURL("path/foo.html"),
+ `${mozExtURL}path/foo.html`,
+ "getURL(path) should return the correct URL"
+ );
+
+ // Permissions
+
+ deepEqual(
+ policy.permissions,
+ ["<all_urls>"],
+ "Initial permissions should be correct"
+ );
+
+ ok(
+ policy.hasPermission("<all_urls>"),
+ "hasPermission should match existing permission"
+ );
+ ok(
+ !policy.hasPermission("history"),
+ "hasPermission should not match nonexistent permission"
+ );
+
+ Assert.throws(
+ () => {
+ policy.permissions[0] = "foo";
+ },
+ TypeError,
+ "Permissions array should be frozen"
+ );
+
+ policy.permissions = ["history"];
+ deepEqual(
+ policy.permissions,
+ ["history"],
+ "Permissions should be updateable as a set"
+ );
+
+ ok(
+ policy.hasPermission("history"),
+ "hasPermission should match existing permission"
+ );
+ ok(
+ !policy.hasPermission("<all_urls>"),
+ "hasPermission should not match nonexistent permission"
+ );
+
+ // Origins
+
+ ok(
+ policy.canAccessURI(newURI("http://foo.bar/quux")),
+ "Should be able to access permitted URI"
+ );
+ ok(
+ policy.canAccessURI(newURI("https://x.baz/foo")),
+ "Should be able to access permitted URI"
+ );
+
+ ok(
+ !policy.canAccessURI(newURI("https://foo.bar/quux")),
+ "Should not be able to access non-permitted URI"
+ );
+
+ policy.allowedOrigins = new MatchPatternSet(["https://foo.bar/"], {
+ ignorePath: true,
+ });
+
+ ok(
+ policy.canAccessURI(newURI("https://foo.bar/quux")),
+ "Should be able to access updated permitted URI"
+ );
+ ok(
+ !policy.canAccessURI(newURI("https://x.baz/foo")),
+ "Should not be able to access removed permitted URI"
+ );
+
+ // Web-accessible resources
+
+ ok(
+ policy.isWebAccessiblePath("/foo/bar"),
+ "Web-accessible glob should be web-accessible"
+ );
+ ok(
+ policy.isWebAccessiblePath("/bar.baz"),
+ "Web-accessible path should be web-accessible"
+ );
+ ok(
+ !policy.isWebAccessiblePath("/bar.baz/quux"),
+ "Non-web-accessible path should not be web-accessible"
+ );
+
+ ok(
+ policy.sourceMayAccessPath(mozExtURI, "/bar.baz"),
+ "Web-accessible path should be web-accessible to self"
+ );
+
+ // Localization
+
+ equal(
+ policy.localize("foo"),
+ "<foo>",
+ "Localization callback should work as expected"
+ );
+
+ // Protocol and lookups.
+
+ let proto = Services.io
+ .getProtocolHandler("moz-extension", uuid)
+ .QueryInterface(Ci.nsISubstitutingProtocolHandler);
+
+ deepEqual(
+ WebExtensionPolicy.getActiveExtensions(),
+ [],
+ "Should have no active extensions"
+ );
+ equal(
+ WebExtensionPolicy.getByID(id),
+ null,
+ "ID lookup should not return extension when not active"
+ );
+ equal(
+ WebExtensionPolicy.getByHostname(uuid),
+ null,
+ "Hostname lookup should not return extension when not active"
+ );
+ Assert.throws(
+ () => proto.resolveURI(mozExtURI),
+ /NS_ERROR_NOT_AVAILABLE/,
+ "URL should not resolve when not active"
+ );
+
+ policy.active = true;
+ equal(policy.active, true, "Active attribute should be updated");
+
+ let exts = WebExtensionPolicy.getActiveExtensions();
+ equal(exts.length, 1, "Should have one active extension");
+ equal(exts[0], policy, "Should have the correct active extension");
+
+ equal(
+ WebExtensionPolicy.getByID(id),
+ policy,
+ "ID lookup should return extension when active"
+ );
+ equal(
+ WebExtensionPolicy.getByHostname(uuid),
+ policy,
+ "Hostname lookup should return extension when active"
+ );
+
+ equal(
+ proto.resolveURI(mozExtURI),
+ baseURL,
+ "URL should resolve correctly while active"
+ );
+
+ policy.active = false;
+ equal(policy.active, false, "Active attribute should be updated");
+
+ deepEqual(
+ WebExtensionPolicy.getActiveExtensions(),
+ [],
+ "Should have no active extensions"
+ );
+ equal(
+ WebExtensionPolicy.getByID(id),
+ null,
+ "ID lookup should not return extension when not active"
+ );
+ equal(
+ WebExtensionPolicy.getByHostname(uuid),
+ null,
+ "Hostname lookup should not return extension when not active"
+ );
+ Assert.throws(
+ () => proto.resolveURI(mozExtURI),
+ /NS_ERROR_NOT_AVAILABLE/,
+ "URL should not resolve when not active"
+ );
+
+ // Conflicting policies.
+
+ // This asserts in debug builds, so only test in non-debug builds.
+ if (!AppConstants.DEBUG) {
+ policy.active = true;
+
+ let attrs = [
+ { id, uuid },
+ { id, uuid: "d916886c-cfdf-482e-b7b1-d7f5b0facfa5" },
+ { id: "foo@quux", uuid },
+ ];
+
+ // eslint-disable-next-line no-shadow
+ for (let { id, uuid } of attrs) {
+ let policy2 = new WebExtensionPolicy({
+ id,
+ mozExtensionHostname: uuid,
+ baseURL: "file://bar/",
+
+ localizeCallback() {},
+
+ allowedOrigins: new MatchPatternSet([]),
+ });
+
+ Assert.throws(
+ () => {
+ policy2.active = true;
+ },
+ /NS_ERROR_UNEXPECTED/,
+ `Should not be able to activate conflicting policy: ${id} ${uuid}`
+ );
+ }
+
+ policy.active = false;
+ }
+});
+
+// mozExtensionHostname is normalized to lower case when using
+// policy.getURL whereas using policy.getByHostname does
+// not. Tests below will fail without case insensitive
+// comparisons in ExtensionPolicyService
+add_task(async function test_WebExtensionPolicy_case_sensitivity() {
+ const id = "policy-case@mochitest";
+ const uuid = "BAD93A23-125C-4B24-ABFC-1CA2692B0610";
+
+ const baseURL = "file:///foo/";
+ const mozExtURL = `moz-extension://${uuid}/`;
+ const mozExtURI = newURI(mozExtURL);
+
+ let policy = new WebExtensionPolicy({
+ id: id,
+ mozExtensionHostname: uuid,
+ baseURL,
+ localizeCallback() {},
+ allowedOrigins: new MatchPatternSet([]),
+ permissions: ["<all_urls>"],
+ });
+ policy.active = true;
+
+ equal(
+ WebExtensionPolicy.getByHostname(uuid)?.mozExtensionHostname,
+ policy.mozExtensionHostname,
+ "Hostname lookup should match policy"
+ );
+
+ equal(
+ WebExtensionPolicy.getByHostname(uuid.toLowerCase())?.mozExtensionHostname,
+ policy.mozExtensionHostname,
+ "Hostname lookup should match policy"
+ );
+
+ equal(policy.getURL(), mozExtURI.spec, "Urls should match policy");
+ ok(
+ policy.sourceMayAccessPath(mozExtURI, "/bar.baz"),
+ "Extension path should be accessible to self"
+ );
+
+ policy.active = false;
+});
+
+add_task(async function test_WebExtensionPolicy_V3() {
+ const id = "foo@bar.baz";
+ const uuid = "ca9d3f23-125c-4b24-abfc-1ca2692b0610";
+ const id2 = "foo-2@bar.baz";
+ const uuid2 = "89383c45-7db4-4999-83f7-f4cc246372cd";
+ const id3 = "foo-3@bar.baz";
+ const uuid3 = "56652231-D7E2-45D1-BDBD-BD3BFF80927E";
+
+ const baseURL = "file:///foo/";
+ const mozExtURL = `moz-extension://${uuid}/`;
+ const mozExtURI = newURI(mozExtURL);
+ const fooSite = newURI("http://foo.bar/");
+ const exampleSite = newURI("https://example.com/");
+
+ let policy = new WebExtensionPolicy({
+ id,
+ mozExtensionHostname: uuid,
+ baseURL,
+ manifestVersion: 3,
+
+ localizeCallback(str) {
+ return `<${str}>`;
+ },
+
+ allowedOrigins: new MatchPatternSet(["http://foo.bar/", "*://*.baz/"], {
+ ignorePath: true,
+ }),
+ permissions: ["<all_urls>"],
+ webAccessibleResources: [
+ {
+ resources: ["/foo/*", "/bar.baz"].map(glob => new MatchGlob(glob)),
+ matches: ["http://foo.bar/"],
+ extension_ids: [id3],
+ },
+ {
+ resources: ["/foo.bar.baz"].map(glob => new MatchGlob(glob)),
+ extension_ids: ["*"],
+ },
+ ],
+ });
+ policy.active = true;
+ equal(
+ WebExtensionPolicy.getByHostname(uuid),
+ policy,
+ "Hostname lookup should match policy"
+ );
+
+ let policy2 = new WebExtensionPolicy({
+ id: id2,
+ mozExtensionHostname: uuid2,
+ baseURL,
+ localizeCallback() {},
+ allowedOrigins: new MatchPatternSet([]),
+ permissions: ["<all_urls>"],
+ });
+ policy2.active = true;
+ equal(
+ WebExtensionPolicy.getByHostname(uuid2),
+ policy2,
+ "Hostname lookup should match policy"
+ );
+
+ let policy3 = new WebExtensionPolicy({
+ id: id3,
+ mozExtensionHostname: uuid3,
+ baseURL,
+ localizeCallback() {},
+ allowedOrigins: new MatchPatternSet([]),
+ permissions: ["<all_urls>"],
+ });
+ policy3.active = true;
+ equal(
+ WebExtensionPolicy.getByHostname(uuid3),
+ policy3,
+ "Hostname lookup should match policy"
+ );
+
+ ok(
+ policy.isWebAccessiblePath("/bar.baz"),
+ "Web-accessible path should be web-accessible"
+ );
+ ok(
+ !policy.isWebAccessiblePath("/bar.baz/quux"),
+ "Non-web-accessible path should not be web-accessible"
+ );
+ // Extension can always access itself
+ ok(
+ policy.sourceMayAccessPath(mozExtURI, "/bar.baz"),
+ "Web-accessible path should be accessible to self"
+ );
+ ok(
+ policy.sourceMayAccessPath(mozExtURI, "/foo.bar.baz"),
+ "Web-accessible path should be accessible to self"
+ );
+
+ ok(
+ !policy.sourceMayAccessPath(newURI(`https://${uuid}/`), "/bar.baz"),
+ "Web-accessible path should not be accessible due to scheme mismatch"
+ );
+
+ // non-matching site cannot access url
+ ok(
+ policy.sourceMayAccessPath(fooSite, "/bar.baz"),
+ "Web-accessible path should be accessible to foo.bar site"
+ );
+ ok(
+ !policy.sourceMayAccessPath(fooSite, "/foo.bar.baz"),
+ "Web-accessible path should not be accessible to foo.bar site"
+ );
+
+ // non-matching site cannot access url
+ ok(
+ !policy.sourceMayAccessPath(exampleSite, "/bar.baz"),
+ "Web-accessible path should not be accessible to example.com"
+ );
+ ok(
+ !policy.sourceMayAccessPath(exampleSite, "/foo.bar.baz"),
+ "Web-accessible path should not be accessible to example.com"
+ );
+
+ let extURI = newURI(policy2.getURL(""));
+ ok(
+ !policy.sourceMayAccessPath(extURI, "/bar.baz"),
+ "Web-accessible path should not be accessible to other extension"
+ );
+ ok(
+ policy.sourceMayAccessPath(extURI, "/foo.bar.baz"),
+ "Web-accessible path should be accessible to other extension"
+ );
+
+ extURI = newURI(policy3.getURL(""));
+ ok(
+ policy.sourceMayAccessPath(extURI, "/bar.baz"),
+ "Web-accessible path should be accessible to other extension"
+ );
+ ok(
+ policy.sourceMayAccessPath(extURI, "/foo.bar.baz"),
+ "Web-accessible path should be accessible to other extension"
+ );
+
+ policy.active = false;
+ policy2.active = false;
+ policy3.active = false;
+});
+
+add_task(async function test_WebExtensionPolicy_registerContentScripts() {
+ const id = "foo@bar.baz";
+ const uuid = "77a7b9d3-e73c-4cf3-97fb-1824868fe00f";
+
+ const id2 = "foo-2@bar.baz";
+ const uuid2 = "89383c45-7db4-4999-83f7-f4cc246372cd";
+
+ const baseURL = "file:///foo/";
+
+ const mozExtURL = `moz-extension://${uuid}/`;
+ const mozExtURL2 = `moz-extension://${uuid2}/`;
+
+ let policy = new WebExtensionPolicy({
+ id,
+ mozExtensionHostname: uuid,
+ baseURL,
+ localizeCallback() {},
+ allowedOrigins: new MatchPatternSet([]),
+ permissions: ["<all_urls>"],
+ });
+
+ let policy2 = new WebExtensionPolicy({
+ id: id2,
+ mozExtensionHostname: uuid2,
+ baseURL,
+ localizeCallback() {},
+ allowedOrigins: new MatchPatternSet([]),
+ permissions: ["<all_urls>"],
+ });
+
+ let script1 = new WebExtensionContentScript(policy, {
+ run_at: "document_end",
+ js: [`${mozExtURL}/registered-content-script.js`],
+ matches: new MatchPatternSet(["http://localhost/data/*"]),
+ });
+
+ let script2 = new WebExtensionContentScript(policy, {
+ run_at: "document_end",
+ css: [`${mozExtURL}/registered-content-style.css`],
+ matches: new MatchPatternSet(["http://localhost/data/*"]),
+ });
+
+ let script3 = new WebExtensionContentScript(policy2, {
+ run_at: "document_end",
+ css: [`${mozExtURL2}/registered-content-style.css`],
+ matches: new MatchPatternSet(["http://localhost/data/*"]),
+ });
+
+ deepEqual(
+ policy.contentScripts,
+ [],
+ "The policy contentScripts is initially empty"
+ );
+
+ policy.registerContentScript(script1);
+
+ deepEqual(
+ policy.contentScripts,
+ [script1],
+ "script1 has been added to the policy contentScripts"
+ );
+
+ Assert.throws(
+ () => policy.registerContentScript(script1),
+ e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE,
+ "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script more than once"
+ );
+
+ Assert.throws(
+ () => policy.registerContentScript(script3),
+ e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE,
+ "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script related to " +
+ "a different extension"
+ );
+
+ Assert.throws(
+ () => policy.unregisterContentScript(script3),
+ e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE,
+ "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script related to " +
+ "a different extension"
+ );
+
+ deepEqual(
+ policy.contentScripts,
+ [script1],
+ "script1 has not been added twice"
+ );
+
+ policy.registerContentScript(script2);
+
+ deepEqual(
+ policy.contentScripts,
+ [script1, script2],
+ "script2 has the last item of the policy contentScripts array"
+ );
+
+ policy.unregisterContentScript(script1);
+
+ deepEqual(
+ policy.contentScripts,
+ [script2],
+ "script1 has been removed from the policy contentscripts"
+ );
+
+ Assert.throws(
+ () => policy.unregisterContentScript(script1),
+ e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE,
+ "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script more than once"
+ );
+
+ deepEqual(
+ policy.contentScripts,
+ [script2],
+ "the policy contentscripts is unmodified when unregistering an unknown contentScript"
+ );
+
+ policy.unregisterContentScript(script2);
+
+ deepEqual(
+ policy.contentScripts,
+ [],
+ "script2 has been removed from the policy contentScripts"
+ );
+});
+
+add_task(async function test_WebExtensionPolicy_static_themes_resources() {
+ const uuid = "0e7ae607-b5b3-4204-9838-c2138c14bc3c";
+ const mozExtURL = `moz-extension://${uuid}/`;
+ const mozExtURI = newURI(mozExtURL);
+
+ let policy = new WebExtensionPolicy({
+ id: "test-extension@mochitest",
+ mozExtensionHostname: uuid,
+ baseURL: "file:///foo/foo/",
+ localizeCallback() {},
+ allowedOrigins: new MatchPatternSet([]),
+ permissions: [],
+ });
+ policy.active = true;
+
+ let staticThemePolicy = new WebExtensionPolicy({
+ id: "statictheme@bar.baz",
+ mozExtensionHostname: "164d05dc-b45b-4731-aefc-7c1691bae9a4",
+ baseURL: "file:///static_theme/",
+ type: "theme",
+ allowedOrigins: new MatchPatternSet([]),
+ localizeCallback() {},
+ });
+
+ staticThemePolicy.active = true;
+
+ ok(
+ staticThemePolicy.sourceMayAccessPath(mozExtURI, "/someresource.ext"),
+ "Active extensions should be allowed to access the static themes resources"
+ );
+
+ policy.active = false;
+
+ ok(
+ !staticThemePolicy.sourceMayAccessPath(mozExtURI, "/someresource.ext"),
+ "Disabled extensions should be disallowed the static themes resources"
+ );
+
+ ok(
+ !staticThemePolicy.sourceMayAccessPath(
+ Services.io.newURI("http://example.com"),
+ "/someresource.ext"
+ ),
+ "Web content should be disallowed the static themes resources"
+ );
+
+ staticThemePolicy.active = false;
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js b/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js
new file mode 100644
index 0000000000..a6d22e8703
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js
@@ -0,0 +1,20 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function change_remote() {
+ let remote = Services.prefs.getBoolPref("extensions.webextensions.remote");
+ Assert.equal(
+ WebExtensionPolicy.useRemoteWebExtensions,
+ remote,
+ "value of useRemoteWebExtensions matches the pref"
+ );
+
+ Services.prefs.setBoolPref("extensions.webextensions.remote", !remote);
+
+ Assert.equal(
+ WebExtensionPolicy.useRemoteWebExtensions,
+ remote,
+ "value of useRemoteWebExtensions is still the same after changing the pref"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js
new file mode 100644
index 0000000000..c860d73cc8
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js
@@ -0,0 +1,303 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+
+const ADDON_ID = "test@web.extension";
+
+const aps = Cc["@mozilla.org/addons/policy-service;1"].getService(
+ Ci.nsIAddonPolicyService
+);
+
+const v2_csp = Preferences.get(
+ "extensions.webextensions.base-content-security-policy"
+);
+const v3_csp = Preferences.get(
+ "extensions.webextensions.base-content-security-policy.v3"
+);
+
+add_task(async function test_invalid_addon_csp() {
+ await Assert.throws(
+ () => aps.getBaseCSP("invalid@missing"),
+ /NS_ERROR_ILLEGAL_VALUE/,
+ "no base csp for non-existent addon"
+ );
+ await Assert.throws(
+ () => aps.getExtensionPageCSP("invalid@missing"),
+ /NS_ERROR_ILLEGAL_VALUE/,
+ "no extension page csp for non-existent addon"
+ );
+});
+
+add_task(async function test_policy_csp() {
+ equal(
+ aps.defaultCSP,
+ Preferences.get("extensions.webextensions.default-content-security-policy"),
+ "Expected default CSP value"
+ );
+
+ const CUSTOM_POLICY = "script-src: 'self' https://xpcshell.test.custom.csp";
+
+ let tests = [
+ {
+ name: "manifest version 2, no custom policy",
+ policyData: {},
+ expectedPolicy: aps.defaultCSP,
+ },
+ {
+ name: "manifest version 2, no custom policy",
+ policyData: {
+ manifestVersion: 2,
+ },
+ expectedPolicy: aps.defaultCSP,
+ },
+ {
+ name: "version 2 custom extension policy",
+ policyData: {
+ extensionPageCSP: CUSTOM_POLICY,
+ },
+ expectedPolicy: CUSTOM_POLICY,
+ },
+ {
+ name: "manifest version 2 set, custom extension policy",
+ policyData: {
+ manifestVersion: 2,
+ extensionPageCSP: CUSTOM_POLICY,
+ },
+ expectedPolicy: CUSTOM_POLICY,
+ },
+ {
+ name: "manifest version 3, no custom policy",
+ policyData: {
+ manifestVersion: 3,
+ },
+ expectedPolicy: aps.defaultCSPV3,
+ },
+ {
+ name: "manifest 3 version set, custom extensionPage policy",
+ policyData: {
+ manifestVersion: 3,
+ extensionPageCSP: CUSTOM_POLICY,
+ },
+ expectedPolicy: CUSTOM_POLICY,
+ },
+ ];
+
+ let policy = null;
+
+ function setExtensionCSP({ manifestVersion, extensionPageCSP }) {
+ if (policy) {
+ policy.active = false;
+ }
+
+ policy = new WebExtensionPolicy({
+ id: ADDON_ID,
+ mozExtensionHostname: ADDON_ID,
+ baseURL: "file:///",
+
+ allowedOrigins: new MatchPatternSet([]),
+ localizeCallback() {},
+
+ manifestVersion,
+ extensionPageCSP,
+ });
+
+ policy.active = true;
+ }
+
+ for (let test of tests) {
+ info(test.name);
+ setExtensionCSP(test.policyData);
+ equal(
+ aps.getBaseCSP(ADDON_ID),
+ test.policyData.manifestVersion == 3 ? v3_csp : v2_csp,
+ "baseCSP is correct"
+ );
+ equal(
+ aps.getExtensionPageCSP(ADDON_ID),
+ test.expectedPolicy,
+ "extensionPageCSP is correct"
+ );
+ }
+});
+
+add_task(async function test_extension_csp() {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+
+ let extension_pages = "script-src 'self'; img-src 'none'";
+
+ let tests = [
+ {
+ name: "manifest_v2 invalid csp results in default csp used",
+ manifest: {
+ content_security_policy: `script-src 'none'`,
+ },
+ expectedPolicy: aps.defaultCSP,
+ },
+ {
+ name: "manifest_v2 allows https protocol",
+ manifest: {
+ manifest_version: 2,
+ content_security_policy: `script-src 'self' https://example.com`,
+ },
+ expectedPolicy: `script-src 'self' https://example.com`,
+ },
+ {
+ name: "manifest_v2 allows unsafe-eval",
+ manifest: {
+ manifest_version: 2,
+ content_security_policy: `script-src 'self' 'unsafe-eval'`,
+ },
+ expectedPolicy: `script-src 'self' 'unsafe-eval'`,
+ },
+ {
+ name: "manifest_v2 allows wasm-unsafe-eval",
+ manifest: {
+ manifest_version: 2,
+ content_security_policy: `script-src 'self' 'wasm-unsafe-eval'`,
+ },
+ expectedPolicy: `script-src 'self' 'wasm-unsafe-eval'`,
+ },
+ {
+ // object-src used to require local sources, but now we accept anything.
+ name: "manifest_v2 allows object-src, with non-local sources",
+ manifest: {
+ manifest_version: 2,
+ content_security_policy: `script-src 'self'; object-src https:'`,
+ },
+ expectedPolicy: `script-src 'self'; object-src https:'`,
+ },
+ {
+ name: "manifest_v3 invalid csp results in default csp used",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'none'`,
+ },
+ },
+ expectedPolicy: aps.defaultCSPV3,
+ },
+ {
+ name: "manifest_v3 forbidden protocol results in default csp used",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' https://*`,
+ },
+ },
+ expectedPolicy: aps.defaultCSPV3,
+ },
+ {
+ name: "manifest_v3 forbidden eval results in default csp used",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' 'unsafe-eval'`,
+ },
+ },
+ expectedPolicy: aps.defaultCSPV3,
+ },
+ {
+ name: "manifest_v3 disallows localhost",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' https://localhost`,
+ },
+ },
+ expectedPolicy: aps.defaultCSPV3,
+ },
+ {
+ name: "manifest_v3 disallows 127.0.0.1",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' https://127.0.0.1`,
+ },
+ },
+ expectedPolicy: aps.defaultCSPV3,
+ },
+ {
+ name: "manifest_v3 allows wasm-unsafe-eval",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' 'wasm-unsafe-eval'`,
+ },
+ },
+ expectedPolicy: `script-src 'self' 'wasm-unsafe-eval'`,
+ },
+ {
+ // object-src used to require local sources, but now we accept anything.
+ name: "manifest_v3 allows object-src, with non-local sources",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self'; object-src https:'`,
+ },
+ },
+ expectedPolicy: `script-src 'self'; object-src https:'`,
+ },
+ {
+ name: "manifest_v2 csp",
+ manifest: {
+ manifest_version: 2,
+ content_security_policy: extension_pages,
+ },
+ expectedPolicy: extension_pages,
+ },
+ {
+ name: "manifest_v2 with no csp, expect default",
+ manifest: {
+ manifest_version: 2,
+ },
+ expectedPolicy: aps.defaultCSP,
+ },
+ {
+ name: "manifest_v3 used with no csp, expect default",
+ manifest: {
+ manifest_version: 3,
+ },
+ expectedPolicy: aps.defaultCSPV3,
+ },
+ {
+ name: "manifest_v3 syntax used",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages,
+ },
+ },
+ expectedPolicy: extension_pages,
+ },
+ ];
+
+ for (let test of tests) {
+ info(test.name);
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: test.manifest,
+ });
+ await extension.startup();
+ let policy = WebExtensionPolicy.getByID(extension.id);
+ equal(
+ policy.baseCSP,
+ test.manifest.manifest_version == 3 ? v3_csp : v2_csp,
+ "baseCSP is correct"
+ );
+ equal(
+ policy.extensionPageCSP,
+ test.expectedPolicy,
+ "extensionPageCSP is correct."
+ );
+ await extension.unload();
+ }
+
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ Services.prefs.clearUserPref("extensions.manifestV3.enabled");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_validator.js b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js
new file mode 100644
index 0000000000..12ba3f93e9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js
@@ -0,0 +1,322 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const cps = Cc["@mozilla.org/addons/content-policy;1"].getService(
+ Ci.nsIAddonContentPolicy
+);
+
+add_task(async function test_csp_validator_flags() {
+ let checkPolicy = (policy, flags, expectedResult, message = null) => {
+ info(`Checking policy: ${policy}`);
+
+ let result = cps.validateAddonCSP(policy, flags);
+ equal(result, expectedResult);
+ };
+
+ let flags = Ci.nsIAddonContentPolicy;
+
+ checkPolicy(
+ "default-src 'self'; script-src 'self' http://localhost",
+ 0,
+ "\u2018script-src\u2019 directive contains a forbidden http: protocol source",
+ "localhost disallowed"
+ );
+ checkPolicy(
+ "default-src 'self'; script-src 'self' http://localhost",
+ flags.CSP_ALLOW_LOCALHOST,
+ null,
+ "localhost allowed"
+ );
+
+ checkPolicy(
+ "default-src 'self'; script-src 'self' 'unsafe-eval'",
+ 0,
+ "\u2018script-src\u2019 directive contains a forbidden 'unsafe-eval' keyword",
+ "eval disallowed"
+ );
+ checkPolicy(
+ "default-src 'self'; script-src 'self' 'unsafe-eval'",
+ flags.CSP_ALLOW_EVAL,
+ null,
+ "eval allowed"
+ );
+
+ checkPolicy(
+ "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'",
+ 0,
+ "\u2018script-src\u2019 directive contains a forbidden 'wasm-unsafe-eval' keyword",
+ "wasm disallowed"
+ );
+ checkPolicy(
+ "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'",
+ flags.CSP_ALLOW_WASM,
+ null,
+ "wasm allowed"
+ );
+ checkPolicy(
+ "default-src 'self'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval'",
+ flags.CSP_ALLOW_EVAL,
+ null,
+ "wasm and eval allowed"
+ );
+
+ checkPolicy(
+ "default-src 'self'; script-src 'self' https://example.com",
+ 0,
+ "\u2018script-src\u2019 directive contains a forbidden https: protocol source",
+ "remote disallowed"
+ );
+ checkPolicy(
+ "default-src 'self'; script-src 'self' https://example.com",
+ flags.CSP_ALLOW_REMOTE,
+ null,
+ "remote allowed"
+ );
+});
+
+add_task(async function test_csp_validator() {
+ let checkPolicy = (policy, expectedResult, message = null) => {
+ info(`Checking policy: ${policy}`);
+
+ let result = cps.validateAddonCSP(
+ policy,
+ Ci.nsIAddonContentPolicy.CSP_ALLOW_ANY
+ );
+ equal(result, expectedResult);
+ };
+
+ checkPolicy("script-src 'self';", null);
+
+ // In the past, object-src was required to be secure and defaulted to 'self'.
+ // But that is no longer required (see bug 1766881).
+ checkPolicy("script-src 'self'; object-src 'self';", null);
+ checkPolicy("script-src 'self'; object-src https:;", null);
+
+ let hash =
+ "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='";
+
+ checkPolicy(
+ `script-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash} 'unsafe-eval'; ` +
+ `object-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash}`,
+ null
+ );
+
+ checkPolicy(
+ "",
+ "Policy is missing a required \u2018script-src\u2019 directive"
+ );
+
+ checkPolicy(
+ "object-src 'none';",
+ "Policy is missing a required \u2018script-src\u2019 directive"
+ );
+
+ checkPolicy(
+ "default-src 'self' http:",
+ "Policy is missing a required \u2018script-src\u2019 directive",
+ "A strict default-src is required as a fallback if script-src is missing"
+ );
+
+ checkPolicy(
+ "default-src 'self' http:; script-src 'self'",
+ null,
+ "A valid script-src removes the need for a strict default-src fallback"
+ );
+
+ checkPolicy(
+ "default-src 'self'",
+ null,
+ "A valid default-src should count as a valid script-src"
+ );
+
+ checkPolicy(
+ "default-src 'self'; script-src 'self'",
+ null,
+ "A valid default-src should count as a valid script-src"
+ );
+
+ checkPolicy(
+ "default-src 'self'; script-src http://example.com",
+ "\u2018script-src\u2019 directive contains a forbidden http: protocol source",
+ "A valid default-src should not allow an invalid script-src directive"
+ );
+
+ checkPolicy(
+ "script-src 'none'",
+ "\u2018script-src\u2019 must include the source 'self'"
+ );
+
+ checkPolicy(
+ "script-src 'self' 'unsafe-inline'",
+ "\u2018script-src\u2019 directive contains a forbidden 'unsafe-inline' keyword"
+ );
+
+ // Localhost is always valid
+ for (let src of [
+ "http://localhost",
+ "https://localhost",
+ "http://127.0.0.1",
+ "https://127.0.0.1",
+ ]) {
+ checkPolicy(`script-src 'self' ${src};`, null);
+ }
+
+ let directives = ["script-src", "worker-src"];
+
+ for (let [directive, other] of [directives, directives.slice().reverse()]) {
+ for (let src of ["https://*", "https://*.blogspot.com", "https://*"]) {
+ checkPolicy(
+ `${directive} 'self' ${src}; ${other} 'self';`,
+ `https: wildcard sources in \u2018${directive}\u2019 directives must include at least one non-generic sub-domain (e.g., *.example.com rather than *.com)`
+ );
+ }
+
+ for (let protocol of ["http", "https"]) {
+ checkPolicy(
+ `${directive} 'self' ${protocol}:; ${other} 'self';`,
+ `${protocol}: protocol requires a host in \u2018${directive}\u2019 directives`
+ );
+ }
+
+ checkPolicy(
+ `${directive} 'self' http://example.com; ${other} 'self';`,
+ `\u2018${directive}\u2019 directive contains a forbidden http: protocol source`
+ );
+
+ for (let protocol of ["ftp", "meh"]) {
+ checkPolicy(
+ `${directive} 'self' ${protocol}:; ${other} 'self';`,
+ `\u2018${directive}\u2019 directive contains a forbidden ${protocol}: protocol source`
+ );
+ }
+
+ checkPolicy(
+ `${directive} 'self' 'nonce-01234'; ${other} 'self';`,
+ `\u2018${directive}\u2019 directive contains a forbidden 'nonce-*' keyword`
+ );
+ }
+});
+
+add_task(async function test_csp_validator_extension_pages() {
+ let checkPolicy = (policy, expectedResult, message = null) => {
+ info(`Checking policy: ${policy}`);
+
+ // While Schemas.jsm uses Ci.nsIAddonContentPolicy.CSP_ALLOW_WASM, we don't
+ // pass that here because we are only verifying that remote scripts are
+ // blocked here.
+ let result = cps.validateAddonCSP(policy, 0);
+ equal(result, expectedResult);
+ };
+
+ checkPolicy("script-src 'self';", null);
+ checkPolicy("script-src 'self'; worker-src 'none'", null);
+ checkPolicy("script-src 'self'; worker-src 'self'", null);
+
+ // In the past, object-src was required to be secure and defaulted to 'self'.
+ // But that is no longer required (see bug 1766881).
+ checkPolicy("script-src 'self'; object-src 'self';", null);
+ checkPolicy("script-src 'self'; object-src https:;", null);
+
+ let hash =
+ "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='";
+
+ checkPolicy(
+ `script-src 'self' moz-extension://09abcdef blob: filesystem: ${hash}; `,
+ null
+ );
+
+ for (let policy of ["", "script-src-elem 'none';", "worker-src 'none';"]) {
+ checkPolicy(
+ policy,
+ "Policy is missing a required \u2018script-src\u2019 directive"
+ );
+ }
+
+ checkPolicy(
+ "default-src 'self' http:; script-src 'self'",
+ null,
+ "A valid script-src removes the need for a strict default-src fallback"
+ );
+
+ checkPolicy(
+ "default-src 'self'",
+ null,
+ "A valid default-src should count as a valid script-src"
+ );
+
+ for (let directive of ["script-src", "worker-src"]) {
+ checkPolicy(
+ `default-src 'self'; ${directive} 'self'`,
+ null,
+ `A valid default-src should count as a valid ${directive}`
+ );
+ checkPolicy(
+ `default-src 'self'; ${directive} http://example.com`,
+ `\u2018${directive}\u2019 directive contains a forbidden http: protocol source`,
+ `A valid default-src should not allow an invalid ${directive} directive`
+ );
+ }
+
+ checkPolicy(
+ "script-src 'none'",
+ "\u2018script-src\u2019 must include the source 'self'"
+ );
+
+ checkPolicy(
+ "script-src 'self' 'unsafe-inline';",
+ "\u2018script-src\u2019 directive contains a forbidden 'unsafe-inline' keyword"
+ );
+
+ checkPolicy(
+ "script-src 'self' 'unsafe-eval';",
+ "\u2018script-src\u2019 directive contains a forbidden 'unsafe-eval' keyword"
+ );
+
+ // Localhost is invalid
+ for (let src of [
+ "http://localhost",
+ "https://localhost",
+ "http://127.0.0.1",
+ "https://127.0.0.1",
+ ]) {
+ const protocol = src.split(":")[0];
+ checkPolicy(
+ `script-src 'self' ${src};`,
+ `\u2018script-src\u2019 directive contains a forbidden ${protocol}: protocol source`
+ );
+ }
+
+ let directives = ["script-src", "worker-src"];
+
+ for (let [directive, other] of [directives, directives.slice().reverse()]) {
+ for (let protocol of ["http", "https"]) {
+ checkPolicy(
+ `${directive} 'self' ${protocol}:; ${other} 'self';`,
+ `${protocol}: protocol requires a host in \u2018${directive}\u2019 directives`
+ );
+ }
+
+ checkPolicy(
+ `${directive} 'self' https://example.com; ${other} 'self';`,
+ `\u2018${directive}\u2019 directive contains a forbidden https: protocol source`
+ );
+
+ checkPolicy(
+ `${directive} 'self' http://example.com; ${other} 'self';`,
+ `\u2018${directive}\u2019 directive contains a forbidden http: protocol source`
+ );
+
+ for (let protocol of ["ftp", "meh"]) {
+ checkPolicy(
+ `${directive} 'self' ${protocol}:; ${other} 'self';`,
+ `\u2018${directive}\u2019 directive contains a forbidden ${protocol}: protocol source`
+ );
+ }
+
+ checkPolicy(
+ `${directive} 'self' 'nonce-01234'; ${other} 'self';`,
+ `\u2018${directive}\u2019 directive contains a forbidden 'nonce-*' keyword`
+ );
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js b/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js
new file mode 100644
index 0000000000..988da4f405
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js
@@ -0,0 +1,80 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { MessageManagerProxy } = ChromeUtils.importESModule(
+ "resource://gre/modules/MessageManagerProxy.sys.mjs"
+);
+const { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+
+class TestMessageManagerProxy extends MessageManagerProxy {
+ constructor(contentPage, identifier) {
+ super(contentPage.browser);
+ this.identifier = identifier;
+ this.contentPage = contentPage;
+ this.deferred = null;
+ }
+
+ // Registers message listeners. Call dispose() once you've finished.
+ async setupPingPongListeners() {
+ await this.contentPage.loadFrameScript(`() => {
+ this.addMessageListener("test:MessageManagerProxy:Ping", ({data}) => {
+ this.sendAsyncMessage("test:MessageManagerProxy:Pong", "${this.identifier}:" + data);
+ });
+ }`);
+
+ // Register the listener here instead of during testPingPong, to make sure
+ // that the listener is correctly registered during the whole test.
+ this.addMessageListener("test:MessageManagerProxy:Pong", event => {
+ ok(
+ this.deferred,
+ `[${this.identifier}] expected to be waiting for ping-pong`
+ );
+ this.deferred.resolve(event.data);
+ this.deferred = null;
+ });
+ }
+
+ async testPingPong(description) {
+ equal(this.deferred, null, "should not be waiting for a message");
+ this.deferred = PromiseUtils.defer();
+ this.sendAsyncMessage("test:MessageManagerProxy:Ping", description);
+ let result = await this.deferred.promise;
+ equal(result, `${this.identifier}:${description}`, "Expected ping-pong");
+ }
+}
+
+// Tests that MessageManagerProxy continues to proxy messages after docshells
+// have been swapped.
+add_task(async function test_message_after_swapdocshells() {
+ let page1 = await ExtensionTestUtils.loadContentPage("about:blank");
+ let page2 = await ExtensionTestUtils.loadContentPage("about:blank");
+
+ let testProxyOne = new TestMessageManagerProxy(page1, "page1");
+ let testProxyTwo = new TestMessageManagerProxy(page2, "page2");
+
+ await testProxyOne.setupPingPongListeners();
+ await testProxyTwo.setupPingPongListeners();
+
+ await testProxyOne.testPingPong("after setup (to 1)");
+ await testProxyTwo.testPingPong("after setup (to 2)");
+
+ page1.browser.swapDocShells(page2.browser);
+
+ await testProxyOne.testPingPong("after docshell swap (to 1)");
+ await testProxyTwo.testPingPong("after docshell swap (to 2)");
+
+ // Swap again to verify that listeners are repeatedly moved when needed.
+ page1.browser.swapDocShells(page2.browser);
+
+ await testProxyOne.testPingPong("after another docshell swap (to 1)");
+ await testProxyTwo.testPingPong("after another docshell swap (to 2)");
+
+ // Verify that dispose() works regardless of the browser's validity.
+ await testProxyOne.dispose();
+ await page1.close();
+ await page2.close();
+ await testProxyTwo.dispose();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js b/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js
new file mode 100644
index 0000000000..00173f3a4d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js
@@ -0,0 +1,78 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.usePrivilegedSignatures = false;
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_setup(async () => {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+// This test should produce a warning, but still startup
+add_task(async function test_api_restricted() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "activityLog-permission@tests.mozilla.org" },
+ },
+ permissions: ["activityLog"],
+ },
+ async background() {
+ browser.test.assertEq(
+ undefined,
+ browser.activityLog,
+ "activityLog is privileged"
+ );
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+ await extension.unload();
+});
+
+// This test should produce a error and not startup
+add_task(
+ {
+ // Some builds (e.g. thunderbird) have experiments enabled by default.
+ pref_set: [["extensions.experiments.enabled", false]],
+ },
+ async function test_api_restricted_temporary_without_privilege() {
+ let extension = ExtensionTestUtils.loadExtension({
+ temporarilyInstalled: true,
+ isPrivileged: false,
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "activityLog-permission@tests.mozilla.org" },
+ },
+ permissions: ["activityLog"],
+ },
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ let { messages } = await promiseConsoleOutput(async () => {
+ await Assert.rejects(
+ extension.startup(),
+ /Using the privileged permission/,
+ "Startup failed with privileged permission"
+ );
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ AddonTestUtils.checkMessages(
+ messages,
+ {
+ expected: [
+ {
+ message:
+ /Using the privileged permission 'activityLog' requires a privileged add-on/,
+ },
+ ],
+ },
+ true
+ );
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js
new file mode 100644
index 0000000000..2d8b02bcd9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js
@@ -0,0 +1,160 @@
+"use strict";
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function test_contentscript_private_field_xrays() {
+ async function contentScript() {
+ let node = window.document.createElement("div");
+
+ class Base {
+ constructor(o) {
+ return o;
+ }
+ }
+
+ class A extends Base {
+ #x = 5;
+ static gx(o) {
+ return o.#x;
+ }
+ static sx(o, v) {
+ o.#x = v;
+ }
+ }
+
+ browser.test.log(A.toString());
+
+ // Stamp node with A's private field.
+ new A(node);
+
+ browser.test.log("stamped");
+
+ browser.test.assertEq(
+ A.gx(node),
+ 5,
+ "We should be able to see our expando private field"
+ );
+ browser.test.log("Read");
+ browser.test.assertThrows(
+ () => A.gx(node.wrappedJSObject),
+ /can't access private field or method/,
+ "Underlying object should not have our private field"
+ );
+
+ browser.test.log("threw");
+ window.frames[0].document.adoptNode(node);
+ browser.test.log("adopted");
+ browser.test.assertEq(
+ A.gx(node),
+ 5,
+ "Adoption should not change expando private field"
+ );
+ browser.test.log("read");
+ browser.test.assertThrows(
+ () => A.gx(node.wrappedJSObject),
+ /can't access private field or method/,
+ "Adoption should really not change expandos private fields"
+ );
+ browser.test.log("threw2");
+
+ // Repeat but now with an object that has a reference from the
+ // window it's being cloned into.
+ node = window.document.createElement("div");
+ // Stamp node with A's private field.
+ new A(node);
+ A.sx(node, 6);
+
+ browser.test.assertEq(
+ A.gx(node),
+ 6,
+ "We should be able to see our expando (2)"
+ );
+ browser.test.assertThrows(
+ () => A.gx(node.wrappedJSObject),
+ /can't access private field or method/,
+ "Underlying object should not have exxpando. (2)"
+ );
+
+ window.frames[0].wrappedJSObject.incoming = node.wrappedJSObject;
+ window.frames[0].document.adoptNode(node);
+
+ browser.test.assertEq(
+ A.gx(node),
+ 6,
+ "We should be able to see our expando (3)"
+ );
+ browser.test.assertThrows(
+ () => A.gx(node.wrappedJSObject),
+ /can't access private field or method/,
+ "Underlying object should not have exxpando. (3)"
+ );
+
+ // Repeat once more, now with an expando that refers to the object itself
+ node = window.document.createElement("div");
+ new A(node);
+ A.sx(node, node);
+
+ browser.test.assertEq(
+ A.gx(node),
+ node,
+ "We should be able to see our self-referential expando (4)"
+ );
+ browser.test.assertThrows(
+ () => A.gx(node.wrappedJSObject),
+ /can't access private field or method/,
+ "Underlying object should not have exxpando. (4)"
+ );
+
+ window.frames[0].document.adoptNode(node);
+
+ browser.test.assertEq(
+ A.gx(node),
+ node,
+ "Adoption should not change our self-referential expando (4)"
+ );
+ browser.test.assertThrows(
+ () => A.gx(node.wrappedJSObject),
+ /can't access private field or method/,
+ "Adoption should not change underlying object. (4)"
+ );
+
+ // And test what happens if we now set document.domain and cause
+ // wrapper remapping.
+ let doc = window.frames[0].document;
+ // eslint-disable-next-line no-self-assign
+ doc.domain = doc.domain;
+
+ browser.test.notifyPass("privateFieldXRayAdoption");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_toplevel.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_toplevel.html`
+ );
+
+ await extension.awaitFinish("privateFieldXRayAdoption");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js
new file mode 100644
index 0000000000..9655c157d1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js
@@ -0,0 +1,129 @@
+"use strict";
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function test_contentscript_xrays() {
+ async function contentScript() {
+ let node = window.document.createElement("div");
+ node.expando = 5;
+
+ browser.test.assertEq(
+ node.expando,
+ 5,
+ "We should be able to see our expando"
+ );
+ browser.test.assertEq(
+ node.wrappedJSObject.expando,
+ undefined,
+ "Underlying object should not have our expando"
+ );
+
+ window.frames[0].document.adoptNode(node);
+ browser.test.assertEq(
+ node.expando,
+ 5,
+ "Adoption should not change expandos"
+ );
+ browser.test.assertEq(
+ node.wrappedJSObject.expando,
+ undefined,
+ "Adoption should really not change expandos"
+ );
+
+ // Repeat but now with an object that has a reference from the
+ // window it's being cloned into.
+ node = window.document.createElement("div");
+ node.expando = 6;
+
+ browser.test.assertEq(
+ node.expando,
+ 6,
+ "We should be able to see our expando (2)"
+ );
+ browser.test.assertEq(
+ node.wrappedJSObject.expando,
+ undefined,
+ "Underlying object should not have our expando (2)"
+ );
+
+ window.frames[0].wrappedJSObject.incoming = node.wrappedJSObject;
+
+ window.frames[0].document.adoptNode(node);
+ browser.test.assertEq(
+ node.expando,
+ 6,
+ "Adoption should not change expandos (2)"
+ );
+ browser.test.assertEq(
+ node.wrappedJSObject.expando,
+ undefined,
+ "Adoption should really not change expandos (2)"
+ );
+
+ // Repeat once more, now with an expando that refers to the object itself.
+ node = window.document.createElement("div");
+ node.expando = node;
+
+ browser.test.assertEq(
+ node.expando,
+ node,
+ "We should be able to see our self-referential expando (3)"
+ );
+ browser.test.assertEq(
+ node.wrappedJSObject.expando,
+ undefined,
+ "Underlying object should not have our self-referential expando (3)"
+ );
+
+ window.frames[0].document.adoptNode(node);
+ browser.test.assertEq(
+ node.expando,
+ node,
+ "Adoption should not change self-referential expando (3)"
+ );
+ browser.test.assertEq(
+ node.wrappedJSObject.expando,
+ undefined,
+ "Adoption should really not change self-referential expando (3)"
+ );
+
+ // And test what happens if we now set document.domain and cause
+ // wrapper remapping.
+ let doc = window.frames[0].document;
+ // eslint-disable-next-line no-self-assign
+ doc.domain = doc.domain;
+
+ browser.test.notifyPass("contentScriptAdoptionWithXrays");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_toplevel.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_toplevel.html`
+ );
+
+ await extension.awaitFinish("contentScriptAdoptionWithXrays");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js
new file mode 100644
index 0000000000..892a82e2e3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js
@@ -0,0 +1,346 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+add_task(
+ {
+ // TODO(Bug 1725478): remove the skip if once webidl API bindings will be hidden based on permissions.
+ skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(),
+ },
+ async function test_alarm_without_permissions() {
+ function backgroundScript() {
+ browser.test.assertTrue(
+ !browser.alarms,
+ "alarm API is not available when the alarm permission is not required"
+ );
+ browser.test.notifyPass("alarms_permission");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})()`,
+ manifest: {
+ permissions: [],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("alarms_permission");
+ await extension.unload();
+ }
+);
+
+add_task(async function test_alarm_clear_non_matching_name() {
+ async function backgroundScript() {
+ let ALARM_NAME = "test_ext_alarms";
+
+ browser.alarms.create(ALARM_NAME, { when: Date.now() + 2000000 });
+
+ let wasCleared = await browser.alarms.clear(ALARM_NAME + "1");
+ browser.test.assertFalse(wasCleared, "alarm was not cleared");
+
+ let alarms = await browser.alarms.getAll();
+ browser.test.assertEq(1, alarms.length, "alarm was not removed");
+ browser.test.notifyPass("alarm-clear");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})()`,
+ manifest: {
+ permissions: ["alarms"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("alarm-clear");
+ await extension.unload();
+});
+
+add_task(async function test_alarm_get_and_clear_single_argument() {
+ async function backgroundScript() {
+ browser.alarms.create({ when: Date.now() + 2000000 });
+
+ let alarm = await browser.alarms.get();
+ browser.test.assertEq("", alarm.name, "expected alarm returned");
+
+ let wasCleared = await browser.alarms.clear();
+ browser.test.assertTrue(wasCleared, "alarm was cleared");
+
+ let alarms = await browser.alarms.getAll();
+ browser.test.assertEq(0, alarms.length, "alarm was removed");
+
+ browser.test.notifyPass("alarm-single-arg");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})()`,
+ manifest: {
+ permissions: ["alarms"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("alarm-single-arg");
+ await extension.unload();
+});
+
+// This test case covers the behavior of browser.alarms.create when the
+// first optional argument (the alarm name) is passed explicitly as null
+// or undefined instead of being omitted.
+add_task(async function test_alarm_name_arg_null_or_undefined() {
+ async function backgroundScript(alarmName) {
+ browser.alarms.create(alarmName, { when: Date.now() + 2000000 });
+
+ let alarm = await browser.alarms.get();
+ browser.test.assertTrue(alarm, "got an alarm");
+ browser.test.assertEq("", alarm.name, "expected alarm returned");
+
+ let wasCleared = await browser.alarms.clear();
+ browser.test.assertTrue(wasCleared, "alarm was cleared");
+
+ let alarms = await browser.alarms.getAll();
+ browser.test.assertEq(0, alarms.length, "alarm was removed");
+
+ browser.test.notifyPass("alarm-test-done");
+ }
+
+ for (const alarmName of [null, undefined]) {
+ info(`Test alarm.create with alarm name ${alarmName}`);
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})(${alarmName})`,
+ manifest: {
+ permissions: ["alarms"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("alarm-test-done");
+ await extension.unload();
+ }
+});
+
+add_task(async function test_get_get_all_clear_all_alarms() {
+ async function backgroundScript() {
+ const ALARM_NAME = "test_alarm";
+
+ let suffixes = [0, 1, 2];
+
+ for (let suffix of suffixes) {
+ browser.alarms.create(ALARM_NAME + suffix, {
+ when: Date.now() + (suffix + 1) * 10000,
+ });
+ }
+
+ let alarms = await browser.alarms.getAll();
+ browser.test.assertEq(
+ suffixes.length,
+ alarms.length,
+ "expected number of alarms were found"
+ );
+ alarms.forEach((alarm, index) => {
+ browser.test.assertEq(
+ ALARM_NAME + index,
+ alarm.name,
+ "alarm has the expected name"
+ );
+ });
+
+ for (let suffix of suffixes) {
+ let alarm = await browser.alarms.get(ALARM_NAME + suffix);
+ browser.test.assertEq(
+ ALARM_NAME + suffix,
+ alarm.name,
+ "alarm has the expected name"
+ );
+ browser.test.sendMessage(`get-${suffix}`);
+ }
+
+ let wasCleared = await browser.alarms.clear(ALARM_NAME + suffixes[0]);
+ browser.test.assertTrue(wasCleared, "alarm was cleared");
+
+ alarms = await browser.alarms.getAll();
+ browser.test.assertEq(2, alarms.length, "alarm was removed");
+
+ let alarm = await browser.alarms.get(ALARM_NAME + suffixes[0]);
+ browser.test.assertEq(undefined, alarm, "non-existent alarm is undefined");
+ browser.test.sendMessage(`get-invalid`);
+
+ wasCleared = await browser.alarms.clearAll();
+ browser.test.assertTrue(wasCleared, "alarms were cleared");
+
+ alarms = await browser.alarms.getAll();
+ browser.test.assertEq(0, alarms.length, "no alarms exist");
+ browser.test.sendMessage("clearAll");
+ browser.test.sendMessage("clear");
+ browser.test.sendMessage("getAll");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})()`,
+ manifest: {
+ permissions: ["alarms"],
+ },
+ });
+
+ await Promise.all([
+ extension.startup(),
+ extension.awaitMessage("getAll"),
+ extension.awaitMessage("get-0"),
+ extension.awaitMessage("get-1"),
+ extension.awaitMessage("get-2"),
+ extension.awaitMessage("clear"),
+ extension.awaitMessage("get-invalid"),
+ extension.awaitMessage("clearAll"),
+ ]);
+ await extension.unload();
+});
+
+function getAlarmExtension(alarmCreateOptions, extOpts = {}) {
+ info(
+ `Test alarms.create fires with options: ${JSON.stringify(
+ alarmCreateOptions
+ )}`
+ );
+
+ function backgroundScript(createOptions) {
+ let ALARM_NAME = "test_ext_alarms";
+ let timer;
+
+ browser.alarms.onAlarm.addListener(alarm => {
+ browser.test.assertEq(
+ ALARM_NAME,
+ alarm.name,
+ "alarm has the expected name"
+ );
+ clearTimeout(timer);
+ browser.test.sendMessage("alarms-create-with-options");
+ });
+
+ browser.alarms.create(ALARM_NAME, createOptions);
+
+ timer = setTimeout(async () => {
+ browser.test.fail("alarm fired within expected time");
+ let wasCleared = await browser.alarms.clear(ALARM_NAME);
+ browser.test.assertTrue(wasCleared, "alarm was cleared");
+ browser.test.sendMessage("alarms-create-with-options");
+ }, 10000);
+ }
+
+ let { persistent, useAddonManager } = extOpts;
+ return ExtensionTestUtils.loadExtension({
+ useAddonManager,
+ // Pass the alarms.create options to the background page.
+ background: `(${backgroundScript})(${JSON.stringify(alarmCreateOptions)})`,
+ manifest: {
+ permissions: ["alarms"],
+ background: { persistent },
+ },
+ });
+}
+
+async function test_alarm_fires_with_options(alarmCreateOptions) {
+ let extension = getAlarmExtension(alarmCreateOptions);
+
+ await extension.startup();
+ await extension.awaitMessage("alarms-create-with-options");
+
+ // Defer unloading the extension so the asynchronous event listener
+ // reply finishes.
+ await new Promise(resolve => setTimeout(resolve, 0));
+ await extension.unload();
+}
+
+add_task(async function test_alarm_fires() {
+ Services.prefs.setBoolPref(
+ "privacy.resistFingerprinting.reduceTimerPrecision.jitter",
+ false
+ );
+
+ await test_alarm_fires_with_options({ delayInMinutes: 0.01 });
+ await test_alarm_fires_with_options({ when: Date.now() + 1000 });
+ await test_alarm_fires_with_options({ delayInMinutes: -10 });
+ await test_alarm_fires_with_options({ when: Date.now() - 1000 });
+
+ Services.prefs.clearUserPref(
+ "privacy.resistFingerprinting.reduceTimerPrecision.jitter"
+ );
+});
+
+function trackEvents(wrapper) {
+ let events = new Map();
+ for (let event of ["background-script-event", "start-background-script"]) {
+ events.set(event, false);
+ wrapper.extension.once(event, () => events.set(event, true));
+ }
+ return events;
+}
+
+add_task(
+ {
+ // TODO(Bug 1748665): remove the skip once background service worker is also
+ // woken up by persistent listeners.
+ skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(),
+ pref_set: [
+ ["privacy.resistFingerprinting.reduceTimerPrecision.jitter", false],
+ ["extensions.eventPages.enabled", true],
+ ],
+ },
+ async function test_alarm_persists() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = getAlarmExtension(
+ { periodInMinutes: 0.01 },
+ { useAddonManager: "permanent", persistent: false }
+ );
+ info(`wait startup`);
+ await extension.startup();
+ assertPersistentListeners(extension, "alarms", "onAlarm", {
+ primed: false,
+ });
+ info(`wait first alarm`);
+ await extension.awaitMessage("alarms-create-with-options");
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ ok(
+ !extension.extension.backgroundContext,
+ "Background Extension context should have been destroyed"
+ );
+
+ assertPersistentListeners(extension, "alarms", "onAlarm", {
+ primed: true,
+ });
+
+ // Test an early startup event
+ let events = trackEvents(extension);
+ ok(
+ !events.get("background-script-event"),
+ "Should not have received a background script event"
+ );
+ ok(
+ !events.get("start-background-script"),
+ "Background script should not be started"
+ );
+
+ info("waiting for alarm to wake background");
+ await extension.awaitMessage("alarms-create-with-options");
+ ok(
+ events.get("background-script-event"),
+ "Should have received a background script event"
+ );
+ ok(
+ events.get("start-background-script"),
+ "Background script should be started"
+ );
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js
new file mode 100644
index 0000000000..fe385004ba
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js
@@ -0,0 +1,34 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+"use strict";
+
+add_task(async function test_cleared_alarm_does_not_fire() {
+ async function backgroundScript() {
+ let ALARM_NAME = "test_ext_alarms";
+
+ browser.alarms.onAlarm.addListener(alarm => {
+ browser.test.fail("cleared alarm does not fire");
+ browser.test.notifyFail("alarm-cleared");
+ });
+ browser.alarms.create(ALARM_NAME, { when: Date.now() + 1000 });
+
+ let wasCleared = await browser.alarms.clear(ALARM_NAME);
+ browser.test.assertTrue(wasCleared, "alarm was cleared");
+
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ browser.test.notifyPass("alarm-cleared");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})()`,
+ manifest: {
+ permissions: ["alarms"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("alarm-cleared");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js
new file mode 100644
index 0000000000..b78d6da649
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js
@@ -0,0 +1,50 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+"use strict";
+
+add_task(async function test_periodic_alarm_fires() {
+ function backgroundScript() {
+ const ALARM_NAME = "test_ext_alarms";
+ let count = 0;
+ let timer;
+
+ browser.alarms.onAlarm.addListener(alarm => {
+ browser.test.assertEq(
+ alarm.name,
+ ALARM_NAME,
+ "alarm has the expected name"
+ );
+ if (count++ === 3) {
+ clearTimeout(timer);
+ browser.alarms.clear(ALARM_NAME).then(wasCleared => {
+ browser.test.assertTrue(wasCleared, "alarm was cleared");
+
+ browser.test.notifyPass("alarm-periodic");
+ });
+ }
+ });
+
+ browser.alarms.create(ALARM_NAME, { periodInMinutes: 0.02 });
+
+ timer = setTimeout(async () => {
+ browser.test.fail("alarm fired expected number of times");
+
+ let wasCleared = await browser.alarms.clear(ALARM_NAME);
+ browser.test.assertTrue(wasCleared, "alarm was cleared");
+
+ browser.test.notifyFail("alarm-periodic");
+ }, 30000);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})()`,
+ manifest: {
+ permissions: ["alarms"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("alarm-periodic");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js
new file mode 100644
index 0000000000..0d7597fa5a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js
@@ -0,0 +1,56 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_duplicate_alarm_name_replaces_alarm() {
+ function backgroundScript() {
+ let count = 0;
+
+ browser.alarms.onAlarm.addListener(async alarm => {
+ browser.test.assertEq(
+ "replaced alarm",
+ alarm.name,
+ "Expected last alarm"
+ );
+ browser.test.assertEq(
+ 0,
+ count++,
+ "duplicate named alarm replaced existing alarm"
+ );
+ let results = await browser.alarms.getAll();
+
+ // "replaced alarm" is expected to be replaced with a non-repeating
+ // alarm, so it should not appear in the list of alarms.
+ browser.test.assertEq(1, results.length, "exactly one alarms exists");
+ browser.test.assertEq(
+ "unrelated alarm",
+ results[0].name,
+ "remaining alarm has the expected name"
+ );
+
+ browser.test.notifyPass("alarm-duplicate");
+ });
+
+ // Alarm that is so far in the future that it is never triggered.
+ browser.alarms.create("unrelated alarm", { delayInMinutes: 60 });
+ // Alarm that repeats.
+ browser.alarms.create("replaced alarm", {
+ delayInMinutes: 1 / 60,
+ periodInMinutes: 1 / 60,
+ });
+ // Before the repeating alarm is triggered, it is immediately replaced with
+ // a non-repeating alarm.
+ browser.alarms.create("replaced alarm", { delayInMinutes: 3 / 60 });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})()`,
+ manifest: {
+ permissions: ["alarms"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("alarm-duplicate");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_api_events_listener_calls_exceptions.js b/toolkit/components/extensions/test/xpcshell/test_ext_api_events_listener_calls_exceptions.js
new file mode 100644
index 0000000000..44ff592d83
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_api_events_listener_calls_exceptions.js
@@ -0,0 +1,369 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExtensionStorageIDB } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorageIDB.sys.mjs"
+);
+
+// Detect if the current build is still using the legacy storage.sync Kinto-based backend
+// (currently only GeckoView builds does have that still enabled).
+//
+// TODO(Bug 1625257): remove this once the rust-based storage.sync backend has been enabled
+// also on GeckoView build and the legacy Kinto-based backend has been ripped off.
+const storageSyncKintoEnabled = Services.prefs.getBoolPref(
+ "webextensions.storage.sync.kinto"
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+server.registerPathHandler("/test-page.html", (req, res) => {
+ res.setHeader("Content-Type", "text/html", false);
+ res.write(`<!DOCTYPE html>
+ <html><body><script>
+ window.onerror = (evt) => {
+ browser.test.log("webpage page got error event, error property set to: " + String(evt.error) + "::" +
+ evt.error?.stack + "\\n");
+ window.postMessage(
+ {
+ message: evt.message,
+ sourceName: evt.filename,
+ lineNumber: evt.lineno,
+ columnNumber: evt.colno,
+ errorIsDefined: !!evt.error,
+ },
+ "*"
+ );
+ };
+ window.errorListenerReady = true;
+ </script></body></html>
+ `);
+});
+
+add_task(async function test_api_listener_call_exception() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "storage",
+ "webRequest",
+ "webRequestBlocking",
+ "http://example.com/*",
+ ],
+ content_scripts: [
+ {
+ js: ["contentscript.js"],
+ matches: ["http://example.com/test-page.html"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "contentscript.js": () => {
+ window.onload = () => {
+ browser.test.assertEq(
+ window.wrappedJSObject.errorListenerReady,
+ true,
+ "Got an onerror listener on the content page side"
+ );
+ browser.test.sendMessage("contentscript-attached");
+ };
+ // eslint-disable-next-line mozilla/balanced-listeners
+ window.addEventListener("message", evt => {
+ browser.test.fail(
+ `Webpage got notified on an exception raised from the content script: ${JSON.stringify(
+ evt.data
+ )}`
+ );
+ });
+ // eslint-disable-next-line mozilla/balanced-listeners
+ window.addEventListener("error", evt => {
+ const errorDetails = {
+ message: evt.message,
+ sourceName: evt.filename,
+ lineNumber: evt.lineno,
+ columnNumber: evt.colno,
+ errorIsDefined: !!evt.error,
+ };
+ browser.test.fail(
+ `Webpage got notified on an exception raised from the content script: ${JSON.stringify(
+ errorDetails
+ )}`
+ );
+ });
+ const throwAnError = () => {
+ throw new Error("test-contentscript-error");
+ };
+ browser.storage.sync.onChanged.addListener(() => {
+ throwAnError();
+ });
+
+ browser.storage.local.onChanged.addListener(() => {
+ throw undefined; // eslint-disable-line no-throw-literal
+ });
+ },
+ "extpage.html": `<!DOCTYPE html><script src="extpage.js"></script>`,
+ "extpage.js": () => {
+ // eslint-disable-next-line mozilla/balanced-listeners
+ window.addEventListener("error", evt => {
+ browser.test.log(
+ `Extension page got error event, error property set to: ${evt.error} :: ${evt.error?.stack}\n`
+ );
+ const errorDetails = {
+ message: evt.message,
+ sourceName: evt.filename,
+ lineNumber: evt.lineno,
+ columnNumber: evt.colno,
+ errorIsDefined: !!evt.error,
+ };
+
+ // Theoretically the exception thrown by a listener registered
+ // from an extension webpage should be emitting an error event
+ // (e.g. like for a DOM Event listener in a similar scenario),
+ // but we never emitted it and so it would be better to only emit
+ // it after have explicitly accepted the slightly change in behavior.
+ browser.test.log(
+ `extension page got notified on an exception raised from the API event listener: ${JSON.stringify(
+ errorDetails
+ )}`
+ );
+ });
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ throw new Error(`Mock webRequest listener exception`);
+ },
+ { urls: ["http://example.com/data/*"] },
+ ["blocking"]
+ );
+
+ // An object with a custom getter for the `message` property and a custom
+ // toString method, both are triggering a test failure to make sure we do
+ // catch with a failure if we are running the extension code as a side effect
+ // of logging the error to the console service.
+ const nonError = {
+ get message() {
+ browser.test.fail(`Unexpected extension code executed`);
+ },
+
+ toString() {
+ browser.test.fail(`Unexpected extension code executed`);
+ },
+ };
+ browser.storage.sync.onChanged.addListener(() => {
+ throw nonError;
+ });
+
+ // Throwing undefined or null is also allowed and so we cover that here as well
+ // to confirm we are not making any assumption about the value being raised to
+ // be always defined.
+ browser.storage.local.onChanged.addListener(() => {
+ throw undefined; // eslint-disable-line no-throw-literal
+ });
+ },
+ },
+ });
+
+ await extension.startup();
+
+ const page = await ExtensionTestUtils.loadContentPage(
+ extension.extension.baseURI.resolve("extpage.html"),
+ { extension }
+ );
+
+ // Prepare to collect the error reported for the exception being triggered
+ // by the test itself.
+ const prepareWaitForConsoleMessage = () => {
+ this.content.waitForConsoleMessage = new Promise(resolve => {
+ const currInnerWindowID = this.content.windowGlobalChild?.innerWindowId;
+ const consoleListener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIConsoleListener"]),
+ observe: message => {
+ if (
+ message instanceof Ci.nsIScriptError &&
+ message.innerWindowID === currInnerWindowID
+ ) {
+ resolve({
+ message: message.message,
+ category: message.category,
+ sourceName: message.sourceName,
+ hasStack: !!message.stack,
+ });
+ Services.console.unregisterListener(consoleListener);
+ }
+ },
+ };
+ Services.console.registerListener(consoleListener);
+ });
+ };
+
+ const notifyStorageSyncListener = extensionTestWrapper => {
+ // The notifyListeners method from ExtensionStorageSyncKinto does use
+ // the Extension class instance as the key for the storage.sync listeners
+ // map, whereas ExtensionStorageSync does use the extension id instead.
+ //
+ // TODO(Bug 1625257): remove this once the rust-based storage.sync backend has been enabled
+ // also on GeckoView build and the legacy Kinto-based backend has been ripped off.
+ let listenersMapKey = storageSyncKintoEnabled
+ ? extensionTestWrapper.extension
+ : extensionTestWrapper.id;
+ ok(
+ ExtensionParent.apiManager.global.extensionStorageSync.listeners.has(
+ listenersMapKey
+ ),
+ "Got a storage.sync onChanged listener for the test extension"
+ );
+ ExtensionParent.apiManager.global.extensionStorageSync.notifyListeners(
+ listenersMapKey,
+ {}
+ );
+ };
+
+ // Retrieve the message collected from the previously created promise.
+ const asyncAssertConsoleMessage = async ({
+ targetPage,
+ expectedErrorRegExp,
+ expectedSourceName,
+ shouldIncludeStack,
+ }) => {
+ const { message, category, sourceName, hasStack } = await targetPage.spawn(
+ [],
+ () => this.content.waitForConsoleMessage
+ );
+
+ ok(
+ expectedErrorRegExp.test(message),
+ `Got the expected error message: ${message}`
+ );
+
+ Assert.deepEqual(
+ { category, sourceName, hasStack },
+ {
+ category: "content javascript",
+ sourceName: expectedSourceName,
+ hasStack: shouldIncludeStack,
+ },
+ "Expected category and sourceName are set on the nsIScriptError"
+ );
+ };
+
+ {
+ info("Test exception raised by webRequest listener");
+ const expectedErrorRegExp = new RegExp(
+ `Error: Mock webRequest listener exception`
+ );
+ const expectedSourceName =
+ extension.extension.baseURI.resolve("extpage.js");
+ await page.spawn([], prepareWaitForConsoleMessage);
+ await ExtensionTestUtils.fetch(
+ "http://example.com",
+ "http://example.com/data/file_sample.html"
+ );
+ await asyncAssertConsoleMessage({
+ targetPage: page,
+ expectedErrorRegExp,
+ expectedSourceName,
+ // TODO(Bug 1810582): this should be expected to be true.
+ shouldIncludeStack: false,
+ });
+ }
+
+ {
+ info("Test exception raised by storage.sync listener");
+ // The listener has throw an object that isn't an Error instance and
+ // it also has a getter for the message property, we expect it to be
+ // logged using the string returned by the native toString method.
+ const expectedErrorRegExp = new RegExp(
+ `uncaught exception: \\[object Object\\]`
+ );
+ // TODO(Bug 1810582): this should be expected to be the script url
+ // where the exception has been originated from.
+ const expectedSourceName =
+ extension.extension.baseURI.resolve("extpage.html");
+
+ await page.spawn([], prepareWaitForConsoleMessage);
+ notifyStorageSyncListener(extension);
+ await asyncAssertConsoleMessage({
+ targetPage: page,
+ expectedErrorRegExp,
+ expectedSourceName,
+ // TODO(Bug 1810582): this should be expected to be true.
+ shouldIncludeStack: false,
+ });
+ }
+
+ {
+ info("Test exception raised by storage.local listener");
+ // The listener has throw an object that isn't an Error instance and
+ // it also has a getter for the message property, we expect it to be
+ // logged using the string returned by the native toString method.
+ const expectedErrorRegExp = new RegExp(`uncaught exception: undefined`);
+ // TODO(Bug 1810582): this should be expected to be the script url
+ // where the exception has been originated from.
+ const expectedSourceName =
+ extension.extension.baseURI.resolve("extpage.html");
+ await page.spawn([], prepareWaitForConsoleMessage);
+ ExtensionStorageIDB.notifyListeners(extension.id, {});
+ await asyncAssertConsoleMessage({
+ targetPage: page,
+ expectedErrorRegExp,
+ expectedSourceName,
+ // TODO(Bug 1810582): this should be expected to be true.
+ shouldIncludeStack: false,
+ });
+ }
+
+ await page.close();
+
+ info("Test content script API event listeners exception");
+
+ const contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/test-page.html"
+ );
+
+ await extension.awaitMessage("contentscript-attached");
+
+ {
+ info("Test exception raised by content script storage.sync listener");
+ // The listener has throw an object that isn't an Error instance and
+ // it also has a getter for the message property, we expect it to be
+ // logged using the string returned by the native toString method.
+ const expectedErrorRegExp = new RegExp(`Error: test-contentscript-error`);
+ const expectedSourceName =
+ extension.extension.baseURI.resolve("contentscript.js");
+
+ await contentPage.spawn([], prepareWaitForConsoleMessage);
+ notifyStorageSyncListener(extension);
+ await asyncAssertConsoleMessage({
+ targetPage: contentPage,
+ expectedErrorRegExp,
+ expectedSourceName,
+ // TODO(Bug 1810582): this should be expected to be true.
+ shouldIncludeStack: false,
+ });
+ }
+
+ {
+ info("Test exception raised by content script storage.local listener");
+ // The listener has throw an object that isn't an Error instance and
+ // it also has a getter for the message property, we expect it to be
+ // logged using the string returned by the native toString method.
+ const expectedErrorRegExp = new RegExp(`uncaught exception: undefined`);
+ // TODO(Bug 1810582): this should be expected to be the script url
+ // where the exception has been originated from.
+ const expectedSourceName = extension.extension.baseURI.resolve("/");
+
+ await contentPage.spawn([], prepareWaitForConsoleMessage);
+ ExtensionStorageIDB.notifyListeners(extension.id, {});
+ await asyncAssertConsoleMessage({
+ targetPage: contentPage,
+ expectedErrorRegExp,
+ expectedSourceName,
+ // TODO(Bug 1810582): this should be expected to be true.
+ shouldIncludeStack: false,
+ });
+ }
+
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js
new file mode 100644
index 0000000000..8083f5c920
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js
@@ -0,0 +1,75 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+);
+function getNextContext() {
+ return new Promise(resolve => {
+ Management.on("proxy-context-load", function listener(type, context) {
+ Management.off("proxy-context-load", listener);
+ resolve(context);
+ });
+ });
+}
+
+add_task(async function test_storage_api_without_permissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ // Force API initialization.
+ try {
+ browser.storage.onChanged.addListener(() => {});
+ } catch (e) {
+ // Ignore.
+ }
+ },
+
+ manifest: {
+ permissions: [],
+ },
+ });
+
+ let contextPromise = getNextContext();
+ await extension.startup();
+
+ let context = await contextPromise;
+
+ // Force API initialization.
+ void context.apiObj;
+
+ ok(
+ !("storage" in context.apiObj),
+ "The storage API should not be initialized"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_storage_api_with_permissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.storage.onChanged.addListener(() => {});
+ },
+
+ manifest: {
+ permissions: ["storage"],
+ },
+ });
+
+ let contextPromise = getNextContext();
+ await extension.startup();
+
+ let context = await contextPromise;
+
+ // Force API initialization.
+ void context.apiObj;
+
+ equal(
+ typeof context.apiObj.storage,
+ "object",
+ "The storage API should be initialized"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_asyncAPICall_isHandlingUserInput.js b/toolkit/components/extensions/test/xpcshell/test_ext_asyncAPICall_isHandlingUserInput.js
new file mode 100644
index 0000000000..73593b7e81
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_asyncAPICall_isHandlingUserInput.js
@@ -0,0 +1,149 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExtensionAPI } = ExtensionCommon;
+
+const API_CLASS = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ testMockAPI: {
+ async anAsyncAPIMethod(...args) {
+ const callContextDataBeforeAwait = context.callContextData;
+ await Promise.resolve();
+ const callContextDataAfterAwait = context.callContextData;
+ return {
+ args,
+ callContextDataBeforeAwait,
+ callContextDataAfterAwait,
+ };
+ },
+ },
+ };
+ }
+};
+
+const API_SCRIPT = `
+ this.testMockAPI = ${API_CLASS.toString()};
+`;
+
+const API_SCHEMA = [
+ {
+ namespace: "testMockAPI",
+ functions: [
+ {
+ name: "anAsyncAPIMethod",
+ type: "function",
+ async: true,
+ parameters: [
+ {
+ name: "param1",
+ type: "object",
+ additionalProperties: {
+ type: "string",
+ },
+ },
+ {
+ name: "param2",
+ type: "string",
+ },
+ ],
+ },
+ ],
+ },
+];
+
+const MODULE_INFO = {
+ testMockAPI: {
+ schema: `data:,${JSON.stringify(API_SCHEMA)}`,
+ scopes: ["addon_parent"],
+ paths: [["testMockAPI"]],
+ url: URL.createObjectURL(new Blob([API_SCRIPT])),
+ },
+};
+
+add_setup(async function () {
+ // The blob:-URL registered above in MODULE_INFO gets loaded at
+ // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649
+ Services.prefs.setBoolPref(
+ "security.allow_parent_unrestricted_js_loads",
+ true
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads");
+ });
+
+ ExtensionParent.apiManager.registerModules(MODULE_INFO);
+});
+
+add_task(
+ async function test_propagated_isHandlingUserInput_on_async_api_methods_calls() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@test-ext" } },
+ },
+ background() {
+ browser.test.onMessage.addListener(async (msg, args) => {
+ if (msg !== "async-method-call") {
+ browser.test.fail(`Unexpected test message: ${msg}`);
+ return;
+ }
+
+ try {
+ let result = await browser.testMockAPI.anAsyncAPIMethod(...args);
+ browser.test.sendMessage("async-method-call:result", result);
+ } catch (err) {
+ browser.test.sendMessage("async-method-call:error", err.message);
+ }
+ });
+ },
+ });
+
+ await extension.startup();
+
+ const callArgs = [{ param1: "param1" }, "param2"];
+
+ info("Test API method called without handling user input");
+
+ extension.sendMessage("async-method-call", callArgs);
+ const result = await extension.awaitMessage("async-method-call:result");
+ Assert.deepEqual(
+ result?.args,
+ callArgs,
+ "Got the expected parameters when called without handling user input"
+ );
+ Assert.deepEqual(
+ result?.callContextDataBeforeAwait,
+ { isHandlingUserInput: false },
+ "Got the expected callContextData before awaiting on a promise"
+ );
+ Assert.deepEqual(
+ result?.callContextDataAfterAwait,
+ null,
+ "context.callContextData should have been nullified after awaiting on a promise"
+ );
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("async-method-call", callArgs);
+ const result = await extension.awaitMessage("async-method-call:result");
+ Assert.deepEqual(
+ result?.args,
+ callArgs,
+ "Got the expected parameters when called while handling user input"
+ );
+ Assert.deepEqual(
+ result?.callContextDataBeforeAwait,
+ { isHandlingUserInput: true },
+ "Got the expected callContextData before awaiting on a promise"
+ );
+ Assert.deepEqual(
+ result?.callContextDataAfterAwait,
+ null,
+ "context.callContextData should have been nullified after awaiting on a promise"
+ );
+ });
+
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js
new file mode 100644
index 0000000000..a603b03a29
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js
@@ -0,0 +1,35 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function testBackgroundWindow() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.log("background script executed");
+ window.location =
+ "http://example.com/data/file_privilege_escalation.html";
+ },
+ });
+
+ let awaitConsole = new Promise(resolve => {
+ Services.console.registerListener(function listener(message) {
+ if (/WebExt Privilege Escalation/.test(message.message)) {
+ Services.console.unregisterListener(listener);
+ resolve(message);
+ }
+ });
+ });
+
+ await extension.startup();
+
+ let message = await awaitConsole;
+ ok(
+ message.message.includes(
+ "WebExt Privilege Escalation: typeof(browser) = undefined"
+ ),
+ "Document does not have `browser` APIs."
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js
new file mode 100644
index 0000000000..7f27348a1d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js
@@ -0,0 +1,187 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+let { promiseRestartManager, promiseShutdownManager, promiseStartupManager } =
+ AddonTestUtils;
+
+const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+);
+
+// Crashes a <browser>'s remote process.
+// Based on BrowserTestUtils.crashFrame.
+function crashFrame(browser) {
+ if (!browser.isRemoteBrowser) {
+ // The browser should be remote, or the test runner would be killed.
+ throw new Error("<browser> must be remote");
+ }
+
+ // Trigger crash by sending a message to BrowserTestUtils actor.
+ BrowserTestUtils.sendAsyncMessage(
+ browser.browsingContext,
+ "BrowserTestUtils:CrashFrame",
+ {}
+ );
+}
+
+// Verifies that a delayed background page is not loaded when an extension is
+// shut down during startup.
+add_task(async function test_unload_extension_before_background_page_startup() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background() {
+ browser.test.sendMessage("background_startup_observed");
+ },
+ });
+
+ // Delayed startup are only enabled for browser (re)starts, so we need to
+ // install the extension first, and then unload it.
+
+ await extension.startup();
+ await extension.awaitMessage("background_startup_observed");
+
+ // Now the actual test: Unloading an extension before the startup has
+ // finished should interrupt the start-up and abort pending delayed loads.
+ info("Starting extension whose startup will be interrupted");
+ await promiseRestartManager({ earlyStartup: false });
+ await extension.awaitStartup();
+
+ let extensionBrowserInsertions = 0;
+ let onExtensionBrowserInserted = () => ++extensionBrowserInsertions;
+ Management.on("extension-browser-inserted", onExtensionBrowserInserted);
+
+ info("Unloading extension before the delayed background page starts loading");
+ await extension.addon.disable();
+
+ // Re-enable the add-on to let enough time pass to load a whole background
+ // page. If at the end of this the original background page hasn't loaded,
+ // we can consider the test successful.
+ await extension.addon.enable();
+
+ // Trigger the notification that would load a background page.
+ info("Forcing pending delayed background page to load");
+ AddonTestUtils.notifyLateStartup();
+
+ // This is the expected message from the re-enabled add-on.
+ await extension.awaitMessage("background_startup_observed");
+ await extension.unload();
+
+ await promiseShutdownManager();
+
+ Management.off("extension-browser-inserted", onExtensionBrowserInserted);
+ Assert.equal(
+ extensionBrowserInsertions,
+ 1,
+ "Extension browser should have been inserted only once"
+ );
+});
+
+// Verifies that the "build" method of BackgroundPage in ext-backgroundPage.js
+// does not deadlock when startup is interrupted by extension shutdown.
+add_task(async function test_unload_extension_during_background_page_startup() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background() {
+ browser.test.sendMessage("background_starting");
+ },
+ });
+
+ // Delayed startup are only enabled for browser (re)starts, so we need to
+ // install the extension first, and then reload it.
+ await extension.startup();
+ await extension.awaitMessage("background_starting");
+
+ await promiseRestartManager({ lateStartup: false });
+ await extension.awaitStartup();
+
+ let bgStartupPromise = new Promise(resolve => {
+ function onBackgroundPageDone(eventName) {
+ extension.extension.off(
+ "background-script-started",
+ onBackgroundPageDone
+ );
+ extension.extension.off(
+ "background-script-aborted",
+ onBackgroundPageDone
+ );
+
+ if (eventName === "background-script-aborted") {
+ info("Background script startup was interrupted");
+ resolve("bg_aborted");
+ } else {
+ info("Background script startup finished normally");
+ resolve("bg_fully_loaded");
+ }
+ }
+ extension.extension.on("background-script-started", onBackgroundPageDone);
+ extension.extension.on("background-script-aborted", onBackgroundPageDone);
+ });
+
+ let bgStartingPromise = new Promise(resolve => {
+ let backgroundLoadCount = 0;
+ let backgroundPageUrl = extension.extension.baseURI.resolve(
+ "_generated_background_page.html"
+ );
+
+ // Prevent the background page from actually loading.
+ Management.once("extension-browser-inserted", (eventName, browser) => {
+ // Intercept background page load.
+ let browserFixupAndLoadURIString = browser.fixupAndLoadURIString;
+ browser.fixupAndLoadURIString = function () {
+ Assert.equal(++backgroundLoadCount, 1, "loadURI should be called once");
+ Assert.equal(
+ arguments[0],
+ backgroundPageUrl,
+ "Expected background page"
+ );
+ // Reset to "about:blank" to not load the actual background page.
+ arguments[0] = "about:blank";
+ browserFixupAndLoadURIString.apply(this, arguments);
+
+ // And force the extension process to crash.
+ if (browser.isRemote) {
+ crashFrame(browser);
+ } else {
+ // If extensions are not running in out-of-process mode, then the
+ // non-remote process should not be killed (or the test runner dies).
+ // Remove <browser> instead, to simulate the immediate disconnection
+ // of the message manager (that would happen if the process crashed).
+ browser.remove();
+ }
+ resolve();
+ };
+ });
+ });
+
+ // Force background page to initialize.
+ AddonTestUtils.notifyLateStartup();
+ await bgStartingPromise;
+
+ await extension.unload();
+ await promiseShutdownManager();
+
+ // This part is the regression test for bug 1501375. It verifies that the
+ // background building completes eventually.
+ // If it does not, then the next line will cause a timeout.
+ info("Waiting for background builder to finish");
+ let bgLoadState = await bgStartupPromise;
+ Assert.equal(bgLoadState, "bg_aborted", "Startup should be interrupted");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js
new file mode 100644
index 0000000000..cac574b8ca
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js
@@ -0,0 +1,23 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* eslint-disable mozilla/balanced-listeners */
+
+add_task(async function test_DOMContentLoaded_in_generated_background_page() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ function reportListener(event) {
+ browser.test.sendMessage("eventname", event.type);
+ }
+ document.addEventListener("DOMContentLoaded", reportListener);
+ window.addEventListener("load", reportListener);
+ },
+ });
+
+ await extension.startup();
+ equal("DOMContentLoaded", await extension.awaitMessage("eventname"));
+ equal("load", await extension.awaitMessage("eventname"));
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js
new file mode 100644
index 0000000000..a22db9d582
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js
@@ -0,0 +1,24 @@
+/* -*- 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_reload_generated_background_page() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ if (location.hash !== "#firstrun") {
+ browser.test.sendMessage("first run");
+ location.hash = "#firstrun";
+ browser.test.assertEq("#firstrun", location.hash);
+ location.reload();
+ } else {
+ browser.test.notifyPass("second run");
+ }
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("first run");
+ await extension.awaitFinish("second run");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js
new file mode 100644
index 0000000000..19a918eff9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js
@@ -0,0 +1,24 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { PlacesTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PlacesTestUtils.sys.mjs"
+);
+
+add_task(async function test_global_history() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("background-loaded", location.href);
+ },
+ });
+
+ await extension.startup();
+
+ let backgroundURL = await extension.awaitMessage("background-loaded");
+
+ await extension.unload();
+
+ let exists = await PlacesTestUtils.isPageInDB(backgroundURL);
+ ok(!exists, "Background URL should not be in history database");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js
new file mode 100644
index 0000000000..9ce80f3fda
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js
@@ -0,0 +1,44 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_background_incognito() {
+ info(
+ "Test background page incognito value with permanent private browsing enabled"
+ );
+
+ Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.privatebrowsing.autostart");
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ async background() {
+ browser.test.assertEq(
+ window,
+ browser.extension.getBackgroundPage(),
+ "Caller should be able to access itself as a background page"
+ );
+ browser.test.assertEq(
+ window,
+ await browser.runtime.getBackgroundPage(),
+ "Caller should be able to access itself as a background page"
+ );
+
+ browser.test.assertEq(
+ browser.extension.inIncognitoContext,
+ true,
+ "inIncognitoContext is true for permanent private browsing"
+ );
+
+ browser.test.notifyPass("incognito");
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitFinish("incognito");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js
new file mode 100644
index 0000000000..aa0976434b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js
@@ -0,0 +1,88 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+function backgroundScript() {
+ let received_ports_number = 0;
+
+ const expected_received_ports_number = 1;
+
+ function countReceivedPorts(port) {
+ received_ports_number++;
+
+ if (port.name == "check-results") {
+ browser.runtime.onConnect.removeListener(countReceivedPorts);
+
+ browser.test.assertEq(
+ expected_received_ports_number,
+ received_ports_number,
+ "invalid connect should not create a port"
+ );
+
+ browser.test.notifyPass("runtime.connect invalid params");
+ }
+ }
+
+ browser.runtime.onConnect.addListener(countReceivedPorts);
+
+ let childFrame = document.createElement("iframe");
+ childFrame.src = "extensionpage.html";
+ document.body.appendChild(childFrame);
+}
+
+function senderScript() {
+ let detected_invalid_connect_params = 0;
+
+ const invalid_connect_params = [
+ // too many params
+ [
+ "fake-extensions-id",
+ { name: "fake-conn-name" },
+ "unexpected third params",
+ ],
+ // invalid params format
+ [{}, {}],
+ ["fake-extensions-id", "invalid-connect-info-format"],
+ ];
+ const expected_detected_invalid_connect_params =
+ invalid_connect_params.length;
+
+ function assertInvalidConnectParamsException(params) {
+ try {
+ browser.runtime.connect(...params);
+ } catch (e) {
+ detected_invalid_connect_params++;
+ browser.test.assertTrue(
+ e.toString().includes("Incorrect argument types for runtime.connect."),
+ "exception message is correct"
+ );
+ }
+ }
+ for (let params of invalid_connect_params) {
+ assertInvalidConnectParamsException(params);
+ }
+ browser.test.assertEq(
+ expected_detected_invalid_connect_params,
+ detected_invalid_connect_params,
+ "all invalid runtime.connect params detected"
+ );
+
+ browser.runtime.connect(browser.runtime.id, { name: "check-results" });
+}
+
+let extensionData = {
+ background: backgroundScript,
+ files: {
+ "senderScript.js": senderScript,
+ "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`,
+ },
+};
+
+add_task(async function test_backgroundRuntimeConnectParams() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitFinish("runtime.connect invalid params");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_service_worker.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_service_worker.js
new file mode 100644
index 0000000000..2efbc52739
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_service_worker.js
@@ -0,0 +1,321 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+AddonTestUtils.overrideCertDB();
+
+add_task(async function setup() {
+ ok(
+ WebExtensionPolicy.useRemoteWebExtensions,
+ "Expect remote-webextensions mode enabled"
+ );
+ ok(
+ WebExtensionPolicy.backgroundServiceWorkerEnabled,
+ "Expect remote-webextensions mode enabled"
+ );
+
+ await AddonTestUtils.promiseStartupManager();
+
+ Services.prefs.setBoolPref("dom.serviceWorkers.testing.enabled", true);
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("dom.serviceWorkers.testing.enabled");
+ Services.prefs.clearUserPref("dom.serviceWorkers.idle_timeout");
+ });
+});
+
+add_task(
+ async function test_fail_spawn_extension_worker_for_disabled_extension() {
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ version: "1.0",
+ background: {
+ service_worker: "sw.js",
+ },
+ browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } },
+ },
+ files: {
+ "page.html": "<!DOCTYPE html><body></body>",
+ "sw.js": "dump('Background ServiceWorker - executed\\n');",
+ },
+ });
+
+ const testWorkerWatcher = new TestWorkerWatcher();
+ let watcher = await testWorkerWatcher.watchExtensionServiceWorker(
+ extension
+ );
+
+ await extension.startup();
+
+ info("Wait for the background service worker to be spawned");
+
+ ok(
+ await watcher.promiseWorkerSpawned,
+ "The extension service worker has been spawned as expected"
+ );
+
+ info("Wait for the background service worker to be terminated");
+ ok(
+ await watcher.terminate(),
+ "The extension service worker has been terminated as expected"
+ );
+
+ const swReg = testWorkerWatcher.getRegistration(extension);
+ ok(swReg, "Got a service worker registration");
+ ok(swReg?.activeWorker, "Got an active worker");
+
+ info("Spawn the active worker by attaching the debugger");
+
+ watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension);
+
+ swReg.activeWorker.attachDebugger();
+ info(
+ "Wait for the background service worker to be spawned after attaching the debugger"
+ );
+ ok(
+ await watcher.promiseWorkerSpawned,
+ "The extension service worker has been spawned as expected"
+ );
+
+ swReg.activeWorker.detachDebugger();
+ info(
+ "Wait for the background service worker to be terminated after detaching the debugger"
+ );
+ ok(
+ await watcher.terminate(),
+ "The extension service worker has been terminated as expected"
+ );
+
+ info(
+ "Disabling the addon policy, and then double-check that the worker can't be spawned"
+ );
+ const policy = WebExtensionPolicy.getByID(extension.id);
+ policy.active = false;
+
+ await Assert.throws(
+ () => swReg.activeWorker.attachDebugger(),
+ /InvalidStateError/,
+ "Got the expected extension when trying to spawn a worker for a disabled addon"
+ );
+
+ info(
+ "Enabling the addon policy and double-check the worker is spawned successfully"
+ );
+ policy.active = true;
+
+ watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension);
+
+ swReg.activeWorker.attachDebugger();
+ info(
+ "Wait for the background service worker to be spawned after attaching the debugger"
+ );
+ ok(
+ await watcher.promiseWorkerSpawned,
+ "The extension service worker has been spawned as expected"
+ );
+
+ swReg.activeWorker.detachDebugger();
+ info(
+ "Wait for the background service worker to be terminated after detaching the debugger"
+ );
+ ok(
+ await watcher.terminate(),
+ "The extension service worker has been terminated as expected"
+ );
+
+ await testWorkerWatcher.destroy();
+ await extension.unload();
+ }
+);
+
+add_task(async function test_serviceworker_lifecycle_events() {
+ async function assertLifecycleEvents({ extension, expected, message }) {
+ const getLifecycleEvents = async () => {
+ const { active } = await this.content.navigator.serviceWorker.ready;
+ const { port1, port2 } = new content.MessageChannel();
+
+ return new Promise(resolve => {
+ port1.onmessage = msg => resolve(msg.data.lifecycleEvents);
+ active.postMessage("test", [port2]);
+ });
+ };
+ const page = await ExtensionTestUtils.loadContentPage(
+ extension.extension.baseURI.resolve("page.html"),
+ { extension }
+ );
+ Assert.deepEqual(
+ await page.spawn([], getLifecycleEvents),
+ expected,
+ `Got the expected lifecycle events on ${message}`
+ );
+ await page.close();
+ }
+
+ const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService(
+ Ci.nsIServiceWorkerManager
+ );
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ background: {
+ service_worker: "sw.js",
+ },
+ browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } },
+ },
+ files: {
+ "page.html": "<!DOCTYPE html><body></body>",
+ "sw.js": `
+ dump('Background ServiceWorker - executed\\n');
+
+ const lifecycleEvents = [];
+ self.oninstall = () => {
+ dump('Background ServiceWorker - oninstall\\n');
+ lifecycleEvents.push("install");
+ };
+ self.onactivate = () => {
+ dump('Background ServiceWorker - onactivate\\n');
+ lifecycleEvents.push("activate");
+ };
+ self.onmessage = (evt) => {
+ dump('Background ServiceWorker - onmessage\\n');
+ evt.ports[0].postMessage({ lifecycleEvents });
+ dump('Background ServiceWorker - postMessage\\n');
+ };
+ `,
+ },
+ });
+
+ const testWorkerWatcher = new TestWorkerWatcher();
+ let watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension);
+
+ await extension.startup();
+
+ await assertLifecycleEvents({
+ extension,
+ expected: ["install", "activate"],
+ message: "initial worker registration",
+ });
+
+ const file = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ file.append("serviceworker.txt");
+ await TestUtils.waitForCondition(
+ () => file.exists(),
+ "Wait for service worker registrations to have been dumped on disk"
+ );
+
+ const managerShutdownCompleted = AddonTestUtils.promiseShutdownManager();
+
+ const firstSwReg = swm.getRegistrationByPrincipal(
+ extension.extension.principal,
+ extension.extension.principal.spec
+ );
+ // Force the worker shutdown (in normal condition the worker would have been
+ // terminated as part of the entire application shutting down).
+ firstSwReg.forceShutdown();
+
+ info(
+ "Wait for the background service worker to be terminated while the app is shutting down"
+ );
+ ok(
+ await watcher.promiseWorkerTerminated,
+ "The extension service worker has been terminated as expected"
+ );
+ await managerShutdownCompleted;
+
+ Assert.equal(
+ firstSwReg,
+ swm.getRegistrationByPrincipal(
+ extension.extension.principal,
+ extension.extension.principal.spec
+ ),
+ "Expect the service worker to not be unregistered on application shutdown"
+ );
+
+ info("Restart AddonManager (mocking Browser instance restart)");
+ // Start the addon manager with `earlyStartup: false` to keep the background service worker
+ // from being started right away:
+ //
+ // - the call to `swm.reloadRegistrationForTest()` that follows is making sure that
+ // the previously registered service worker is in the same state it would be when
+ // the entire browser is restarted.
+ //
+ // - if the background service worker is being spawned again by the time we call
+ // `swm.reloadRegistrationForTest()`, ServiceWorkerUpdateJob would fail and trigger
+ // an `mState == State::Started` diagnostic assertion from ServiceWorkerJob::Finish
+ // and the xpcshell test will fail for the crash triggered by the assertion.
+ await AddonTestUtils.promiseStartupManager({ lateStartup: false });
+ await extension.awaitStartup();
+
+ info(
+ "Force reload ServiceWorkerManager registrations (mocking a Browser instance restart)"
+ );
+ swm.reloadRegistrationsForTest();
+
+ info(
+ "trigger delayed call to nsIServiceWorkerManager.registerForAddonPrincipal"
+ );
+ // complete the startup notifications, then start the background
+ AddonTestUtils.notifyLateStartup();
+ extension.extension.emit("start-background-script");
+
+ info("Force activate the extension worker");
+ const newSwReg = swm.getRegistrationByPrincipal(
+ extension.extension.principal,
+ extension.extension.principal.spec
+ );
+
+ Assert.notEqual(
+ newSwReg,
+ firstSwReg,
+ "Expect the service worker registration to have been recreated"
+ );
+
+ await assertLifecycleEvents({
+ extension,
+ expected: [],
+ message: "on previous registration loaded",
+ });
+
+ const { principal } = extension.extension;
+ const addon = await AddonManager.getAddonByID(extension.id);
+ await addon.disable();
+
+ ok(
+ await watcher.promiseWorkerTerminated,
+ "The extension service worker has been terminated as expected"
+ );
+
+ Assert.throws(
+ () => swm.getRegistrationByPrincipal(principal, principal.spec),
+ /NS_ERROR_FAILURE/,
+ "Expect the service worker to have been unregistered on addon disabled"
+ );
+
+ await addon.enable();
+ await assertLifecycleEvents({
+ extension,
+ expected: ["install", "activate"],
+ message: "on disabled addon re-enabled",
+ });
+
+ await testWorkerWatcher.destroy();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js
new file mode 100644
index 0000000000..1c3180b1b6
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js
@@ -0,0 +1,46 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function testBackgroundWindow() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.log("background script executed");
+
+ browser.test.sendMessage("background-script-load");
+
+ let img = document.createElement("img");
+ img.src =
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
+ document.body.appendChild(img);
+
+ img.onload = () => {
+ browser.test.log("image loaded");
+
+ let iframe = document.createElement("iframe");
+ iframe.src = "about:blank?1";
+
+ iframe.onload = () => {
+ browser.test.log("iframe loaded");
+ setTimeout(() => {
+ browser.test.notifyPass("background sub-window test done");
+ }, 0);
+ };
+ document.body.appendChild(iframe);
+ };
+ },
+ });
+
+ let loadCount = 0;
+ extension.onMessage("background-script-load", () => {
+ loadCount++;
+ });
+
+ await extension.startup();
+
+ await extension.awaitFinish("background sub-window test done");
+
+ equal(loadCount, 1, "background script loaded only once");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js
new file mode 100644
index 0000000000..243bc27867
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js
@@ -0,0 +1,98 @@
+"use strict";
+
+add_task(async function test_background_reload_and_unload() {
+ let events = [];
+ {
+ const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+ );
+ let record = (type, extensionContext) => {
+ let eventType = type == "proxy-context-load" ? "load" : "unload";
+ let url = extensionContext.uri.spec;
+ let extensionId = extensionContext.extension.id;
+ events.push({ eventType, url, extensionId });
+ };
+
+ Management.on("proxy-context-load", record);
+ Management.on("proxy-context-unload", record);
+ registerCleanupFunction(() => {
+ Management.off("proxy-context-load", record);
+ Management.off("proxy-context-unload", record);
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.onMessage.addListener(msg => {
+ browser.test.assertEq("reload-background", msg);
+ location.reload();
+ });
+ browser.test.sendMessage("background-url", location.href);
+ },
+ });
+
+ await extension.startup();
+ let backgroundUrl = await extension.awaitMessage("background-url");
+
+ let contextEvents = events.splice(0);
+ equal(
+ contextEvents.length,
+ 1,
+ "ExtensionContext state change after loading an extension"
+ );
+ equal(contextEvents[0].eventType, "load");
+ equal(
+ contextEvents[0].url,
+ backgroundUrl,
+ "The ExtensionContext should be the background page"
+ );
+
+ extension.sendMessage("reload-background");
+ await extension.awaitMessage("background-url");
+
+ contextEvents = events.splice(0);
+ equal(
+ contextEvents.length,
+ 2,
+ "ExtensionContext state changes after reloading the background page"
+ );
+ equal(
+ contextEvents[0].eventType,
+ "unload",
+ "Unload ExtensionContext of background page"
+ );
+ equal(
+ contextEvents[0].url,
+ backgroundUrl,
+ "ExtensionContext URL = background"
+ );
+ equal(
+ contextEvents[1].eventType,
+ "load",
+ "Create new ExtensionContext for background page"
+ );
+ equal(
+ contextEvents[1].url,
+ backgroundUrl,
+ "ExtensionContext URL = background"
+ );
+
+ await extension.unload();
+
+ contextEvents = events.splice(0);
+ equal(
+ contextEvents.length,
+ 1,
+ "ExtensionContext state change after unloading the extension"
+ );
+ equal(
+ contextEvents[0].eventType,
+ "unload",
+ "Unload ExtensionContext for background page after extension unloads"
+ );
+ equal(
+ contextEvents[0].url,
+ backgroundUrl,
+ "ExtensionContext URL = background"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js
new file mode 100644
index 0000000000..1c018005c4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js
@@ -0,0 +1,98 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const HISTOGRAM = "WEBEXT_BACKGROUND_PAGE_LOAD_MS";
+const HISTOGRAM_KEYED = "WEBEXT_BACKGROUND_PAGE_LOAD_MS_BY_ADDONID";
+
+add_task(async function test_telemetry() {
+ let extension1 = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("loaded");
+ },
+ });
+
+ let extension2 = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("loaded");
+ },
+ });
+
+ clearHistograms();
+
+ assertHistogramEmpty(HISTOGRAM);
+ assertKeyedHistogramEmpty(HISTOGRAM_KEYED);
+
+ await extension1.startup();
+ await extension1.awaitMessage("loaded");
+
+ const processSnapshot = snapshot => {
+ return snapshot.sum > 0;
+ };
+
+ const processKeyedSnapshot = snapshot => {
+ let res = {};
+ for (let key of Object.keys(snapshot)) {
+ res[key] = snapshot[key].sum > 0;
+ }
+ return res;
+ };
+
+ assertHistogramSnapshot(
+ HISTOGRAM,
+ { processSnapshot, expectedValue: true },
+ `Data recorded for first extension for histogram: ${HISTOGRAM}.`
+ );
+
+ assertHistogramSnapshot(
+ HISTOGRAM_KEYED,
+ {
+ keyed: true,
+ processSnapshot: processKeyedSnapshot,
+ expectedValue: {
+ [extension1.extension.id]: true,
+ },
+ },
+ `Data recorded for first extension for histogram ${HISTOGRAM_KEYED}`
+ );
+
+ let histogram = Services.telemetry.getHistogramById(HISTOGRAM);
+ let histogramKeyed =
+ Services.telemetry.getKeyedHistogramById(HISTOGRAM_KEYED);
+ let histogramSum = histogram.snapshot().sum;
+ let histogramSumExt1 = histogramKeyed.snapshot()[extension1.extension.id].sum;
+
+ await extension2.startup();
+ await extension2.awaitMessage("loaded");
+
+ assertHistogramSnapshot(
+ HISTOGRAM,
+ {
+ processSnapshot: snapshot => snapshot.sum > histogramSum,
+ expectedValue: true,
+ },
+ `Data recorded for second extension for histogram: ${HISTOGRAM}.`
+ );
+
+ assertHistogramSnapshot(
+ HISTOGRAM_KEYED,
+ {
+ keyed: true,
+ processSnapshot: processKeyedSnapshot,
+ expectedValue: {
+ [extension1.extension.id]: true,
+ [extension2.extension.id]: true,
+ },
+ },
+ `Data recorded for second extension for histogram ${HISTOGRAM_KEYED}`
+ );
+
+ equal(
+ histogramKeyed.snapshot()[extension1.extension.id].sum,
+ histogramSumExt1,
+ `Data recorder for first extension is unchanged on the keyed histogram ${HISTOGRAM_KEYED}`
+ );
+
+ await extension1.unload();
+ await extension2.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_type_module.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_type_module.js
new file mode 100644
index 0000000000..74512e1e41
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_type_module.js
@@ -0,0 +1,133 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function assertBackgroundScriptTypes(
+ extensionTestWrapper,
+ expectedScriptTypesMap
+) {
+ const { baseURI } = extensionTestWrapper.extension;
+ let expectedMapWithResolvedURLs = Object.keys(expectedScriptTypesMap).reduce(
+ (result, scriptPath) => {
+ result[baseURI.resolve(scriptPath)] = expectedScriptTypesMap[scriptPath];
+ return result;
+ },
+ {}
+ );
+ const page = await ExtensionTestUtils.loadContentPage(
+ baseURI.resolve("_generated_background_page.html")
+ );
+ const scriptTypesMap = await page.spawn([], () => {
+ const scripts = Array.from(
+ this.content.document.querySelectorAll("script")
+ );
+ return scripts.reduce((result, script) => {
+ result[script.getAttribute("src")] = script.getAttribute("type");
+ return result;
+ }, {});
+ });
+ await page.close();
+ Assert.deepEqual(
+ scriptTypesMap,
+ expectedMapWithResolvedURLs,
+ "Got the expected script type from the generated background page"
+ );
+}
+
+async function testBackgroundScriptClassic({ manifestTypeClassicSet }) {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ background: {
+ scripts: ["anotherScript.js", "main.js"],
+ type: manifestTypeClassicSet ? "classic" : undefined,
+ },
+ },
+ files: {
+ "main.js": ``,
+ "anotherScript.js": ``,
+ },
+ });
+
+ await extension.startup();
+ await assertBackgroundScriptTypes(extension, {
+ "main.js": "text/javascript",
+ "anotherScript.js": "text/javascript",
+ });
+ await extension.unload();
+}
+
+add_task(async function test_background_scripts_type_default() {
+ await testBackgroundScriptClassic({ manifestTypeClassicSet: false });
+});
+
+add_task(async function test_background_scripts_type_classic() {
+ await testBackgroundScriptClassic({ manifestTypeClassicSet: true });
+});
+
+add_task(async function test_background_scripts_type_module() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ background: {
+ scripts: ["anotherModule.js", "mainModule.js"],
+ type: "module",
+ },
+ },
+ files: {
+ "mainModule.js": `
+ import { initBackground } from "/importedModule.js";
+ browser.test.log("mainModule.js - ESM module executing");
+ initBackground();
+ `,
+ "importedModule.js": `
+ export function initBackground() {
+ browser.test.onMessage.addListener((msg) => {
+ browser.test.log("importedModule.js - test message received");
+ browser.test.sendMessage("esm-module-reply", msg);
+ });
+ browser.test.log("importedModule.js - initBackground executed");
+ }
+ browser.test.log("importedModule.js - ESM module loaded");
+ `,
+ "anotherModule.js": `
+ browser.test.log("anotherModule.js - ESM module loaded");
+ `,
+ },
+ });
+
+ await extension.startup();
+ await extension.sendMessage("test-event-value");
+ equal(
+ await extension.awaitMessage("esm-module-reply"),
+ "test-event-value",
+ "Got the expected event from the ESM module loaded from the background script"
+ );
+ await assertBackgroundScriptTypes(extension, {
+ "mainModule.js": "module",
+ "anotherModule.js": "module",
+ });
+ await extension.unload();
+});
+
+add_task(async function test_background_scripts_type_invalid() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ background: {
+ scripts: ["anotherScript.js", "main.js"],
+ type: "invalid",
+ },
+ },
+ files: {
+ "main.js": ``,
+ "anotherScript.js": ``,
+ },
+ });
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await Assert.rejects(
+ extension.startup(),
+ /Error processing background: .* \.type must be one of/,
+ "Expected install to fail"
+ );
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js
new file mode 100644
index 0000000000..fb2ca27482
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js
@@ -0,0 +1,41 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function testBackgroundWindowProperties() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ let expectedValues = {
+ screenX: 0,
+ screenY: 0,
+ outerWidth: 0,
+ outerHeight: 0,
+ };
+
+ for (let k in window) {
+ try {
+ if (k in expectedValues) {
+ browser.test.assertEq(
+ expectedValues[k],
+ window[k],
+ `should return the expected value for window property: ${k}`
+ );
+ } else {
+ void window[k];
+ }
+ } catch (e) {
+ browser.test.assertEq(
+ null,
+ e,
+ `unexpected exception accessing window property: ${k}`
+ );
+ }
+ }
+
+ browser.test.notifyPass("background.testWindowProperties.done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("background.testWindowProperties.done");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js b/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js
new file mode 100644
index 0000000000..c066147268
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js
@@ -0,0 +1,54 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/*
+ * This test extension has a background script 'missing.js' that is missing
+ * from the XPI. Such an extension should install/uninstall cleanly without
+ * causing timeouts.
+ */
+add_task(async function testXPIMissingBackGroundScript() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ background: {
+ scripts: ["missing.js"],
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.unload();
+ ok(true, "load/unload completed without timing out");
+});
+
+/*
+ * This test extension includes a page with a missing script. The
+ * extension should install/uninstall cleanly without causing hangs.
+ */
+add_task(async function testXPIMissingPageScript() {
+ async function pageScript() {
+ 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="missing.js"></script>
+ <script src="page.js"></script>
+ </head></html>`,
+ "page.js": pageScript,
+ },
+ });
+
+ await extension.startup();
+ let url = await extension.awaitMessage("ready");
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+ await extension.awaitMessage("pageReady");
+ await extension.unload();
+ await contentPage.close();
+
+ ok(true, "load/unload completed without timing out");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js
new file mode 100644
index 0000000000..f1f681b240
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js
@@ -0,0 +1,528 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+const SETTINGS_ID = "test_settings_staged_restart_webext@tests.mozilla.org";
+
+const { createAppInfo, promiseShutdownManager, promiseStartupManager } =
+ AddonTestUtils;
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+
+add_task(async function test_browser_settings() {
+ const PERM_DENY_ACTION = Services.perms.DENY_ACTION;
+ const PERM_UNKNOWN_ACTION = Services.perms.UNKNOWN_ACTION;
+
+ // Create an object to hold the values to which we will initialize the prefs.
+ const PREFS = {
+ "browser.cache.disk.enable": true,
+ "browser.cache.memory.enable": true,
+ "dom.popup_allowed_events": Preferences.get("dom.popup_allowed_events"),
+ "image.animation_mode": "none",
+ "permissions.default.desktop-notification": PERM_UNKNOWN_ACTION,
+ "ui.context_menus.after_mouseup": false,
+ "browser.tabs.closeTabByDblclick": false,
+ "browser.tabs.loadBookmarksInTabs": false,
+ "browser.search.openintab": false,
+ "browser.tabs.insertRelatedAfterCurrent": true,
+ "browser.tabs.insertAfterCurrent": false,
+ "browser.display.document_color_use": 1,
+ "layout.css.prefers-color-scheme.content-override": 2,
+ "browser.display.use_document_fonts": 1,
+ "browser.zoom.full": true,
+ "browser.zoom.siteSpecific": true,
+ };
+
+ async function background() {
+ let listeners = new Set([]);
+ browser.test.onMessage.addListener(async (msg, apiName, value) => {
+ let apiObj = browser.browserSettings;
+ let apiNameSplit = apiName.split(".");
+ for (let apiPart of apiNameSplit) {
+ apiObj = apiObj[apiPart];
+ }
+ if (msg == "get") {
+ browser.test.sendMessage("settingData", await apiObj.get({}));
+ return;
+ }
+
+ // set and setNoOp
+
+ // Don't add more than one listner per apiName. We leave the
+ // listener to ensure we do not get more calls than we expect.
+ if (!listeners.has(apiName)) {
+ apiObj.onChange.addListener(details => {
+ browser.test.sendMessage("onChange", {
+ details,
+ setting: apiName,
+ });
+ });
+ listeners.add(apiName);
+ }
+ let result = await apiObj.set({ value });
+ if (msg === "set") {
+ browser.test.assertTrue(result, "set returns true.");
+ browser.test.sendMessage("settingData", await apiObj.get({}));
+ } else {
+ browser.test.assertFalse(result, "set returns false for a no-op.");
+ browser.test.sendMessage("no-op set");
+ }
+ });
+ }
+
+ // Set prefs to our initial values.
+ for (let pref in PREFS) {
+ Preferences.set(pref, PREFS[pref]);
+ }
+
+ registerCleanupFunction(() => {
+ // Reset the prefs.
+ for (let pref in PREFS) {
+ Preferences.reset(pref);
+ }
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["browserSettings"],
+ },
+ useAddonManager: "temporary",
+ });
+
+ await promiseStartupManager();
+ await extension.startup();
+
+ async function testSetting(setting, value, expected, expectedValue = value) {
+ extension.sendMessage("set", setting, value);
+ let data = await extension.awaitMessage("settingData");
+ let dataChange = await extension.awaitMessage("onChange");
+ equal(setting, dataChange.setting, "onChange fired");
+ equal(
+ data.value,
+ dataChange.details.value,
+ "onChange fired with correct value"
+ );
+ deepEqual(
+ data.value,
+ expectedValue,
+ `The ${setting} setting has the expected value.`
+ );
+ equal(
+ data.levelOfControl,
+ "controlled_by_this_extension",
+ `The ${setting} setting has the expected levelOfControl.`
+ );
+ for (let pref in expected) {
+ equal(
+ Preferences.get(pref),
+ expected[pref],
+ `${pref} set correctly for ${value}`
+ );
+ }
+ }
+
+ async function testNoOpSetting(setting, value, expected) {
+ extension.sendMessage("setNoOp", setting, value);
+ await extension.awaitMessage("no-op set");
+ for (let pref in expected) {
+ equal(
+ Preferences.get(pref),
+ expected[pref],
+ `${pref} set correctly for ${value}`
+ );
+ }
+ }
+
+ await testSetting("cacheEnabled", false, {
+ "browser.cache.disk.enable": false,
+ "browser.cache.memory.enable": false,
+ });
+ await testSetting("cacheEnabled", true, {
+ "browser.cache.disk.enable": true,
+ "browser.cache.memory.enable": true,
+ });
+
+ await testSetting("allowPopupsForUserEvents", false, {
+ "dom.popup_allowed_events": "",
+ });
+ await testSetting("allowPopupsForUserEvents", true, {
+ "dom.popup_allowed_events": PREFS["dom.popup_allowed_events"],
+ });
+
+ for (let value of ["normal", "none", "once"]) {
+ await testSetting("imageAnimationBehavior", value, {
+ "image.animation_mode": value,
+ });
+ }
+
+ await testSetting("webNotificationsDisabled", true, {
+ "permissions.default.desktop-notification": PERM_DENY_ACTION,
+ });
+ await testSetting("webNotificationsDisabled", false, {
+ // This pref is not defaulted on Android.
+ "permissions.default.desktop-notification":
+ AppConstants.MOZ_BUILD_APP !== "browser"
+ ? undefined
+ : PERM_UNKNOWN_ACTION,
+ });
+
+ // This setting is a no-op on Android.
+ if (AppConstants.platform === "android") {
+ await testNoOpSetting("contextMenuShowEvent", "mouseup", {
+ "ui.context_menus.after_mouseup": false,
+ });
+ } else {
+ await testSetting("contextMenuShowEvent", "mouseup", {
+ "ui.context_menus.after_mouseup": true,
+ });
+ }
+
+ // "mousedown" is also a no-op on Windows.
+ if (["android", "win"].includes(AppConstants.platform)) {
+ await testNoOpSetting("contextMenuShowEvent", "mousedown", {
+ "ui.context_menus.after_mouseup": AppConstants.platform === "win",
+ });
+ } else {
+ await testSetting("contextMenuShowEvent", "mousedown", {
+ "ui.context_menus.after_mouseup": false,
+ });
+ }
+
+ if (AppConstants.platform !== "android") {
+ await testSetting("closeTabsByDoubleClick", true, {
+ "browser.tabs.closeTabByDblclick": true,
+ });
+ await testSetting("closeTabsByDoubleClick", false, {
+ "browser.tabs.closeTabByDblclick": false,
+ });
+ }
+
+ extension.sendMessage("get", "ftpProtocolEnabled");
+ let data = await extension.awaitMessage("settingData");
+ equal(data.value, false);
+ equal(
+ data.levelOfControl,
+ "not_controllable",
+ `ftpProtocolEnabled is not controllable.`
+ );
+
+ await testSetting("newTabPosition", "afterCurrent", {
+ "browser.tabs.insertRelatedAfterCurrent": false,
+ "browser.tabs.insertAfterCurrent": true,
+ });
+ await testSetting("newTabPosition", "atEnd", {
+ "browser.tabs.insertRelatedAfterCurrent": false,
+ "browser.tabs.insertAfterCurrent": false,
+ });
+ await testSetting("newTabPosition", "relatedAfterCurrent", {
+ "browser.tabs.insertRelatedAfterCurrent": true,
+ "browser.tabs.insertAfterCurrent": false,
+ });
+
+ await testSetting("openBookmarksInNewTabs", true, {
+ "browser.tabs.loadBookmarksInTabs": true,
+ });
+ await testSetting("openBookmarksInNewTabs", false, {
+ "browser.tabs.loadBookmarksInTabs": false,
+ });
+
+ await testSetting("openSearchResultsInNewTabs", true, {
+ "browser.search.openintab": true,
+ });
+ await testSetting("openSearchResultsInNewTabs", false, {
+ "browser.search.openintab": false,
+ });
+
+ await testSetting("openUrlbarResultsInNewTabs", true, {
+ "browser.urlbar.openintab": true,
+ });
+ await testSetting("openUrlbarResultsInNewTabs", false, {
+ "browser.urlbar.openintab": false,
+ });
+
+ await testSetting("overrideDocumentColors", "high-contrast-only", {
+ "browser.display.document_color_use": 0,
+ });
+ await testSetting("overrideDocumentColors", "never", {
+ "browser.display.document_color_use": 1,
+ });
+ await testSetting("overrideDocumentColors", "always", {
+ "browser.display.document_color_use": 2,
+ });
+
+ await testSetting("overrideContentColorScheme", "dark", {
+ "layout.css.prefers-color-scheme.content-override": 0,
+ });
+ await testSetting("overrideContentColorScheme", "light", {
+ "layout.css.prefers-color-scheme.content-override": 1,
+ });
+ await testSetting("overrideContentColorScheme", "auto", {
+ "layout.css.prefers-color-scheme.content-override": 2,
+ });
+
+ await testSetting("useDocumentFonts", false, {
+ "browser.display.use_document_fonts": 0,
+ });
+ await testSetting("useDocumentFonts", true, {
+ "browser.display.use_document_fonts": 1,
+ });
+
+ await testSetting("zoomFullPage", true, {
+ "browser.zoom.full": true,
+ });
+ await testSetting("zoomFullPage", false, {
+ "browser.zoom.full": false,
+ });
+
+ await testSetting("zoomSiteSpecific", true, {
+ "browser.zoom.siteSpecific": true,
+ });
+ await testSetting("zoomSiteSpecific", false, {
+ "browser.zoom.siteSpecific": false,
+ });
+
+ await testSetting("colorManagement.mode", "off", {
+ "gfx.color_management.mode": 0,
+ });
+ await testSetting("colorManagement.mode", "full", {
+ "gfx.color_management.mode": 1,
+ });
+ await testSetting("colorManagement.mode", "tagged_only", {
+ "gfx.color_management.mode": 2,
+ });
+
+ await testSetting("colorManagement.useNativeSRGB", false, {
+ "gfx.color_management.native_srgb": false,
+ });
+ await testSetting("colorManagement.useNativeSRGB", true, {
+ "gfx.color_management.native_srgb": true,
+ });
+
+ await testSetting("colorManagement.useWebRenderCompositor", false, {
+ "gfx.webrender.compositor": false,
+ });
+ await testSetting("colorManagement.useWebRenderCompositor", true, {
+ "gfx.webrender.compositor": true,
+ });
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+add_task(async function test_bad_value() {
+ async function background() {
+ await browser.test.assertRejects(
+ browser.browserSettings.contextMenuShowEvent.set({ value: "bad" }),
+ /bad is not a valid value for contextMenuShowEvent/,
+ "contextMenuShowEvent.set rejects with an invalid value."
+ );
+
+ await browser.test.assertRejects(
+ browser.browserSettings.overrideDocumentColors.set({ value: 2 }),
+ /2 is not a valid value for overrideDocumentColors/,
+ "overrideDocumentColors.set rejects with an invalid value."
+ );
+
+ await browser.test.assertRejects(
+ browser.browserSettings.overrideDocumentColors.set({ value: "bad" }),
+ /bad is not a valid value for overrideDocumentColors/,
+ "overrideDocumentColors.set rejects with an invalid value."
+ );
+
+ await browser.test.assertRejects(
+ browser.browserSettings.overrideContentColorScheme.set({ value: 0 }),
+ /0 is not a valid value for overrideContentColorScheme/,
+ "overrideContentColorScheme.set rejects with an invalid value."
+ );
+
+ await browser.test.assertRejects(
+ browser.browserSettings.overrideContentColorScheme.set({ value: "bad" }),
+ /bad is not a valid value for overrideContentColorScheme/,
+ "overrideContentColorScheme.set rejects with an invalid value."
+ );
+
+ await browser.test.assertRejects(
+ browser.browserSettings.zoomFullPage.set({ value: 0 }),
+ /0 is not a valid value for zoomFullPage/,
+ "zoomFullPage.set rejects with an invalid value."
+ );
+
+ await browser.test.assertRejects(
+ browser.browserSettings.zoomFullPage.set({ value: "bad" }),
+ /bad is not a valid value for zoomFullPage/,
+ "zoomFullPage.set rejects with an invalid value."
+ );
+
+ await browser.test.assertRejects(
+ browser.browserSettings.zoomSiteSpecific.set({ value: 0 }),
+ /0 is not a valid value for zoomSiteSpecific/,
+ "zoomSiteSpecific.set rejects with an invalid value."
+ );
+
+ await browser.test.assertRejects(
+ browser.browserSettings.zoomSiteSpecific.set({ value: "bad" }),
+ /bad is not a valid value for zoomSiteSpecific/,
+ "zoomSiteSpecific.set rejects with an invalid value."
+ );
+
+ browser.test.sendMessage("done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["browserSettings"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_bad_value_android() {
+ if (AppConstants.platform !== "android") {
+ return;
+ }
+
+ async function background() {
+ await browser.test.assertRejects(
+ browser.browserSettings.closeTabsByDoubleClick.set({ value: true }),
+ /android is not a supported platform for the closeTabsByDoubleClick setting/,
+ "closeTabsByDoubleClick.set rejects on Android."
+ );
+
+ await browser.test.assertRejects(
+ browser.browserSettings.closeTabsByDoubleClick.get({}),
+ /android is not a supported platform for the closeTabsByDoubleClick setting/,
+ "closeTabsByDoubleClick.get rejects on Android."
+ );
+
+ await browser.test.assertRejects(
+ browser.browserSettings.closeTabsByDoubleClick.clear({}),
+ /android is not a supported platform for the closeTabsByDoubleClick setting/,
+ "closeTabsByDoubleClick.clear rejects on Android."
+ );
+
+ browser.test.sendMessage("done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["browserSettings"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+// Verifies settings remain after a staged update on restart.
+add_task(async function delay_updates_settings_after_restart() {
+ let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+ AddonTestUtils.registerJSON(server, "/test_update.json", {
+ addons: {
+ "test_settings_staged_restart_webext@tests.mozilla.org": {
+ updates: [
+ {
+ version: "2.0",
+ update_link:
+ "http://example.com/addons/test_settings_staged_restart_v2.xpi",
+ },
+ ],
+ },
+ },
+ });
+ const update_xpi = AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Delay Upgrade",
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: { id: SETTINGS_ID },
+ },
+ permissions: ["browserSettings"],
+ },
+ });
+ server.registerFile(
+ `/addons/test_settings_staged_restart_v2.xpi`,
+ update_xpi
+ );
+
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: SETTINGS_ID,
+ update_url: `http://example.com/test_update.json`,
+ },
+ },
+ permissions: ["browserSettings"],
+ },
+ background() {
+ browser.runtime.onUpdateAvailable.addListener(async details => {
+ if (details) {
+ await browser.browserSettings.webNotificationsDisabled.set({
+ value: true,
+ });
+ if (details.version) {
+ // This should be the version of the pending update.
+ browser.test.assertEq("2.0", details.version, "correct version");
+ browser.test.notifyPass("delay");
+ }
+ } else {
+ browser.test.fail("no details object passed");
+ }
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+ let prefname = "permissions.default.desktop-notification";
+ let val = Services.prefs.getIntPref(prefname);
+ Assert.notEqual(val, 2, "webNotificationsDisabled pref not set");
+
+ let update = await AddonTestUtils.promiseFindAddonUpdates(extension.addon);
+ let install = update.updateAvailable;
+ Assert.ok(install, `install is available ${update.error}`);
+
+ await AddonTestUtils.promiseCompleteAllInstalls([install]);
+
+ Assert.equal(install.state, AddonManager.STATE_POSTPONED);
+ await extension.awaitFinish("delay");
+
+ // restarting allows upgrade to proceed
+ await AddonTestUtils.promiseRestartManager();
+
+ await extension.awaitStartup();
+
+ // If an update is not handled correctly we would fail here. Bug 1639705.
+ val = Services.prefs.getIntPref(prefname);
+ Assert.equal(val, 2, "webNotificationsDisabled pref set");
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+
+ val = Services.prefs.getIntPref(prefname);
+ Assert.notEqual(val, 2, "webNotificationsDisabled pref not set");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js
new file mode 100644
index 0000000000..8d1d16c743
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js
@@ -0,0 +1,36 @@
+/* -*- 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_homepage_get_without_set() {
+ async function background() {
+ let homepage = await browser.browserSettings.homepageOverride.get({});
+ browser.test.sendMessage("homepage", homepage);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["browserSettings"],
+ },
+ });
+
+ let defaultHomepage = Services.prefs.getStringPref(
+ "browser.startup.homepage"
+ );
+
+ await extension.startup();
+ let homepage = await extension.awaitMessage("homepage");
+ equal(
+ homepage.value,
+ defaultHomepage,
+ "The homepageOverride setting has the expected value."
+ );
+ equal(
+ homepage.levelOfControl,
+ "not_controllable",
+ "The homepageOverride setting has the expected levelOfControl."
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browser_style_deprecation.js b/toolkit/components/extensions/test/xpcshell/test_ext_browser_style_deprecation.js
new file mode 100644
index 0000000000..2046242865
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_browser_style_deprecation.js
@@ -0,0 +1,335 @@
+"use strict";
+
+AddonTestUtils.init(this);
+// This test expects and checks deprecation warnings.
+ExtensionTestUtils.failOnSchemaWarnings(false);
+
+const PREF_SUPPORTED = "extensions.browser_style_mv3.supported";
+const PREF_SAME_AS_MV2 = "extensions.browser_style_mv3.same_as_mv2";
+
+// Set the prefs to the defaults at the end of the deprecation process.
+// TODO bug 1830711: remove these two lines.
+Services.prefs.setBoolPref(PREF_SUPPORTED, false);
+Services.prefs.setBoolPref(PREF_SAME_AS_MV2, false);
+
+function checkBrowserStyleInManifestKey(extension, key, expected) {
+ let actual = extension.extension.manifest[key].browser_style;
+ Assert.strictEqual(actual, expected, `Expected browser_style of "${key}"`);
+}
+
+const BROWSER_STYLE_MV2_DEFAULTS = "BROWSER_STYLE_MV2_DEFAULTS";
+async function checkBrowserStyle({
+ manifest_version = 3,
+ browser_style_in_manifest = null,
+ expected_browser_style,
+ expected_warnings,
+}) {
+ const actionKey = manifest_version === 2 ? "browser_action" : "action";
+ // sidebar_action is implemented in browser/ and therefore only available to
+ // Firefox desktop and not other toolkit apps such as Firefox for Android,
+ // Thunderbird, etc.
+ const IS_SIDEBAR_SUPPORTED = AppConstants.MOZ_BUILD_APP === "browser";
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ options_ui: {
+ page: "options.html",
+ browser_style: browser_style_in_manifest,
+ },
+ [actionKey]: {
+ browser_style: browser_style_in_manifest,
+ },
+ page_action: {
+ browser_style: browser_style_in_manifest,
+ },
+ sidebar_action: {
+ default_panel: "sidebar.html",
+ browser_style: browser_style_in_manifest,
+ },
+ },
+ });
+ await extension.startup();
+ if (expected_browser_style === BROWSER_STYLE_MV2_DEFAULTS) {
+ checkBrowserStyleInManifestKey(extension, "options_ui", true);
+ checkBrowserStyleInManifestKey(extension, actionKey, false);
+ checkBrowserStyleInManifestKey(extension, "page_action", false);
+ if (IS_SIDEBAR_SUPPORTED) {
+ checkBrowserStyleInManifestKey(extension, "sidebar_action", true);
+ }
+ } else {
+ let value = expected_browser_style;
+ checkBrowserStyleInManifestKey(extension, "options_ui", value);
+ checkBrowserStyleInManifestKey(extension, actionKey, value);
+ checkBrowserStyleInManifestKey(extension, "page_action", value);
+ if (IS_SIDEBAR_SUPPORTED) {
+ checkBrowserStyleInManifestKey(extension, "sidebar_action", value);
+ }
+ }
+ if (!IS_SIDEBAR_SUPPORTED) {
+ expected_warnings = expected_warnings.filter(
+ msg => !msg.includes("sidebar_action")
+ );
+ expected_warnings.unshift(
+ `Reading manifest: Warning processing sidebar_action: An unexpected property was found in the WebExtension manifest.`
+ );
+ }
+ const warnings = extension.extension.warnings;
+ await extension.unload();
+ Assert.deepEqual(
+ warnings,
+ expected_warnings,
+ `Got expected warnings for MV${manifest_version} extension with browser_style:${browser_style_in_manifest}.`
+ );
+}
+
+async function checkBrowserStyleWithOpenInTabTrue({
+ manifest_version = 3,
+ browser_style_in_manifest = null,
+ expected_browser_style,
+}) {
+ info(
+ `Testing options_ui.open_in_tab=true + browser_style=${browser_style_in_manifest} for MV${manifest_version} extension`
+ );
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ options_ui: {
+ page: "options.html",
+ browser_style: browser_style_in_manifest,
+ open_in_tab: true,
+ },
+ },
+ });
+ await extension.startup();
+ checkBrowserStyleInManifestKey(
+ extension,
+ "options_ui",
+ expected_browser_style
+ );
+ const warnings = extension.extension.warnings;
+ await extension.unload();
+ Assert.deepEqual(
+ warnings,
+ [],
+ "Expected no warnings on extension with options_ui.open_in_tab true"
+ );
+}
+
+async function repeatTestIndependentOfPref_browser_style_same_as_mv2(testFn) {
+ for (let same_as_mv2 of [true, false]) {
+ await runWithPrefs([[PREF_SAME_AS_MV2, same_as_mv2]], testFn);
+ }
+}
+async function repeatTestIndependentOf_browser_style_deprecation_prefs(testFn) {
+ for (let supported of [true, false]) {
+ for (let same_as_mv2 of [true, false]) {
+ await runWithPrefs(
+ [
+ [PREF_SUPPORTED, supported],
+ [PREF_SAME_AS_MV2, same_as_mv2],
+ ],
+ testFn
+ );
+ }
+ }
+}
+
+add_task(async function browser_style_never_deprecated_in_MV2() {
+ async function check_browser_style_never_deprecated_in_MV2() {
+ await checkBrowserStyle({
+ manifest_version: 2,
+ browser_style_in_manifest: true,
+ expected_browser_style: true,
+ expected_warnings: [],
+ });
+ await checkBrowserStyle({
+ manifest_version: 2,
+ browser_style_in_manifest: false,
+ expected_browser_style: false,
+ expected_warnings: [],
+ });
+ await checkBrowserStyle({
+ manifest_version: 2,
+ browser_style_in_manifest: null,
+ expected_browser_style: "BROWSER_STYLE_MV2_DEFAULTS",
+ expected_warnings: [],
+ });
+
+ // When open_in_tab is true, browser_style is not used and its value does
+ // not matter. Since we want the parsed value to be false in MV3, and the
+ // implementation is simpler if consistently applied to MV2, browser_style
+ // is false when open_in_tab is true (even if browser_style:true is set).
+ await checkBrowserStyleWithOpenInTabTrue({
+ manifest_version: 2,
+ browser_style_in_manifest: null,
+ expected_browser_style: false,
+ });
+ await checkBrowserStyleWithOpenInTabTrue({
+ manifest_version: 2,
+ browser_style_in_manifest: true,
+ expected_browser_style: false,
+ });
+ }
+ // Regardless of all potential test configurations, browser_style is never
+ // deprecated in MV2.
+ await repeatTestIndependentOf_browser_style_deprecation_prefs(
+ check_browser_style_never_deprecated_in_MV2
+ );
+});
+
+add_task(async function open_in_tab_implies_browser_style_false_MV3() {
+ // Regardless of all potential test configurations, when
+ // options_ui.open_in_tab is true, options_ui.browser_style should be false,
+ // because it being true would print deprecation warnings in MV3, and
+ // browser_style:true does not have any effect when open_in_tab is true.
+ await repeatTestIndependentOf_browser_style_deprecation_prefs(async () => {
+ await checkBrowserStyleWithOpenInTabTrue({
+ manifest_version: 3,
+ browser_style_in_manifest: null,
+ expected_browser_style: false,
+ });
+ await checkBrowserStyleWithOpenInTabTrue({
+ manifest_version: 3,
+ browser_style_in_manifest: true,
+ expected_browser_style: false,
+ });
+ });
+});
+
+// Disable browser_style:true - bug 1830711.
+add_task(async function unsupported_and_browser_style_true() {
+ await checkBrowserStyle({
+ manifest_version: 3,
+ browser_style_in_manifest: true,
+ expected_browser_style: false,
+ expected_warnings: [
+ // TODO bug 1830712: Update warnings when max_manifest_version:2 is used.
+ `Reading manifest: Warning processing options_ui.browser_style: "browser_style:true" is no longer supported in Manifest Version 3.`,
+ `Reading manifest: Warning processing action.browser_style: "browser_style:true" is no longer supported in Manifest Version 3.`,
+ `Reading manifest: Warning processing page_action.browser_style: "browser_style:true" is no longer supported in Manifest Version 3.`,
+ `Reading manifest: Warning processing sidebar_action.browser_style: "browser_style:true" is no longer supported in Manifest Version 3.`,
+ ],
+ });
+});
+
+add_task(async function unsupported_and_browser_style_false() {
+ await checkBrowserStyle({
+ manifest_version: 3,
+ browser_style_in_manifest: false,
+ expected_browser_style: false,
+ // TODO bug 1830712: Add warnings when max_manifest_version:2 is used.
+ expected_warnings: [],
+ });
+});
+
+add_task(async function unsupported_and_browser_style_default() {
+ await checkBrowserStyle({
+ manifest_version: 3,
+ browser_style_in_manifest: null,
+ expected_browser_style: false,
+ expected_warnings: [],
+ });
+});
+
+add_task(
+ { pref_set: [[PREF_SUPPORTED, true]] },
+ async function supported_with_browser_style_true() {
+ await repeatTestIndependentOfPref_browser_style_same_as_mv2(async () => {
+ await checkBrowserStyle({
+ manifest_version: 3,
+ browser_style_in_manifest: true,
+ expected_browser_style: true,
+ expected_warnings: [
+ `Reading manifest: Warning processing options_ui.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`,
+ `Reading manifest: Warning processing action.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`,
+ `Reading manifest: Warning processing page_action.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`,
+ `Reading manifest: Warning processing sidebar_action.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`,
+ ],
+ });
+ });
+ }
+);
+
+add_task(
+ { pref_set: [[PREF_SUPPORTED, true]] },
+ async function supported_with_browser_style_false() {
+ await repeatTestIndependentOfPref_browser_style_same_as_mv2(async () => {
+ await checkBrowserStyle({
+ manifest_version: 3,
+ browser_style_in_manifest: false,
+ expected_browser_style: false,
+ expected_warnings: [],
+ });
+ });
+ }
+);
+
+// Initial prefs - warn only - https://bugzilla.mozilla.org/show_bug.cgi?id=1827910#c1
+add_task(
+ {
+ pref_set: [
+ [PREF_SUPPORTED, true],
+ [PREF_SAME_AS_MV2, true],
+ ],
+ },
+ async function supported_with_mv2_defaults() {
+ const makeWarning = manifestKey =>
+ `Reading manifest: Warning processing ${manifestKey}.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future. While "${manifestKey}.browser_style" was not explicitly specified in manifest.json, its default value was true. Its default will change to false in Manifest Version 3 starting from Firefox 115.`;
+ await checkBrowserStyle({
+ manifest_version: 3,
+ browser_style_in_manifest: null,
+ expected_browser_style: "BROWSER_STYLE_MV2_DEFAULTS",
+ expected_warnings: [
+ makeWarning("options_ui"),
+ makeWarning("sidebar_action"),
+ ],
+ });
+ }
+);
+
+// Deprecation + change defaults - bug 1830710.
+add_task(
+ {
+ pref_set: [
+ [PREF_SUPPORTED, true],
+ [PREF_SAME_AS_MV2, false],
+ ],
+ },
+ async function supported_with_browser_style_default_false() {
+ const makeWarning = manifestKey =>
+ `Reading manifest: Warning processing ${manifestKey}.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future. While "${manifestKey}.browser_style" was not explicitly specified in manifest.json, its default value was true. The default value of "${manifestKey}.browser_style" has changed from true to false in Manifest Version 3.`;
+ await checkBrowserStyle({
+ manifest_version: 3,
+ browser_style_in_manifest: null,
+ expected_browser_style: false,
+ expected_warnings: [
+ makeWarning("options_ui"),
+ makeWarning("sidebar_action"),
+ ],
+ });
+ }
+);
+
+// While we are not planning to set this pref combination, users can do so if
+// they desire.
+add_task(
+ {
+ pref_set: [
+ [PREF_SUPPORTED, false],
+ [PREF_SAME_AS_MV2, true],
+ ],
+ },
+ async function unsupported_with_mv2_defaults() {
+ const makeWarning = manifestKey =>
+ `Reading manifest: Warning processing ${manifestKey}.browser_style: "browser_style:true" is no longer supported in Manifest Version 3. While "${manifestKey}.browser_style" was not explicitly specified in manifest.json, its default value was true. Its default will change to false in Manifest Version 3 starting from Firefox 115.`;
+ await checkBrowserStyle({
+ manifest_version: 3,
+ browser_style_in_manifest: null,
+ expected_browser_style: false,
+ expected_warnings: [
+ makeWarning("options_ui"),
+ makeWarning("sidebar_action"),
+ ],
+ });
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js
new file mode 100644
index 0000000000..1df5e60478
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js
@@ -0,0 +1,48 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function testInvalidArguments() {
+ async function background() {
+ const UNSUPPORTED_DATA_TYPES = ["appcache", "fileSystems", "webSQL"];
+
+ await browser.test.assertRejects(
+ browser.browsingData.remove(
+ { originTypes: { protectedWeb: true } },
+ { cookies: true }
+ ),
+ "Firefox does not support protectedWeb or extension as originTypes.",
+ "Expected error received when using protectedWeb originType."
+ );
+
+ await browser.test.assertRejects(
+ browser.browsingData.removeCookies({ originTypes: { extension: true } }),
+ "Firefox does not support protectedWeb or extension as originTypes.",
+ "Expected error received when using extension originType."
+ );
+
+ for (let dataType of UNSUPPORTED_DATA_TYPES) {
+ let dataTypes = {};
+ dataTypes[dataType] = true;
+ browser.test.assertThrows(
+ () => browser.browsingData.remove({}, dataTypes),
+ /Type error for parameter dataToRemove/,
+ `Expected error received when using ${dataType} dataType.`
+ );
+ }
+
+ browser.test.notifyPass("invalidArguments");
+ }
+
+ let extensionData = {
+ background: background,
+ manifest: {
+ permissions: ["browsingData"],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitFinish("invalidArguments");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js
new file mode 100644
index 0000000000..577d727a49
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js
@@ -0,0 +1,456 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+"use strict";
+
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+
+const COOKIE = {
+ host: "example.com",
+ name: "test_cookie",
+ path: "/",
+};
+const COOKIE_NET = {
+ host: "example.net",
+ name: "test_cookie",
+ path: "/",
+};
+const COOKIE_ORG = {
+ host: "example.org",
+ name: "test_cookie",
+ path: "/",
+};
+let since, oldCookie;
+
+function addCookie(cookie) {
+ Services.cookies.add(
+ cookie.host,
+ cookie.path,
+ cookie.name,
+ "test",
+ false,
+ false,
+ false,
+ Date.now() / 1000 + 10000,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTPS
+ );
+ ok(
+ Services.cookies.cookieExists(cookie.host, cookie.path, cookie.name, {}),
+ `Cookie ${cookie.name} was created.`
+ );
+}
+
+async function setUpCookies() {
+ Services.cookies.removeAll();
+
+ // Add a cookie which will end up with an older creationTime.
+ oldCookie = Object.assign({}, COOKIE, { name: Date.now() });
+ addCookie(oldCookie);
+ await new Promise(resolve => setTimeout(resolve, 10));
+ since = Date.now();
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ // Add a cookie which will end up with a more recent creationTime.
+ addCookie(COOKIE);
+
+ // Add cookies for different domains.
+ addCookie(COOKIE_NET);
+ addCookie(COOKIE_ORG);
+}
+
+async function setUpCache() {
+ Services.cache2.clear();
+
+ // Add cache entries for different domains.
+ for (const domain of ["example.net", "example.org", "example.com"]) {
+ await SiteDataTestUtils.addCacheEntry(`http://${domain}/`, "disk");
+ await SiteDataTestUtils.addCacheEntry(`http://${domain}/`, "memory");
+ }
+}
+
+function hasCacheEntry(domain) {
+ const disk = SiteDataTestUtils.hasCacheEntry(`http://${domain}/`, "disk");
+ const memory = SiteDataTestUtils.hasCacheEntry(`http://${domain}/`, "memory");
+
+ equal(
+ disk,
+ memory,
+ `For ${domain} either either both or neither caches need to exists.`
+ );
+ return disk;
+}
+
+add_task(async function testCache() {
+ function background() {
+ browser.test.onMessage.addListener(async msg => {
+ if (msg == "removeCache") {
+ await browser.browsingData.removeCache({});
+ } else {
+ await browser.browsingData.remove({}, { cache: true });
+ }
+ browser.test.sendMessage("cacheRemoved");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["browsingData"],
+ },
+ });
+
+ async function testRemovalMethod(method) {
+ await setUpCache();
+
+ extension.sendMessage(method);
+ await extension.awaitMessage("cacheRemoved");
+
+ ok(!hasCacheEntry("example.net"), "example.net cache was removed");
+ ok(!hasCacheEntry("example.org"), "example.org cache was removed");
+ ok(!hasCacheEntry("example.com"), "example.com cache was removed");
+ }
+
+ await extension.startup();
+
+ await testRemovalMethod("removeCache");
+ await testRemovalMethod("remove");
+
+ await extension.unload();
+});
+
+add_task(async function testCookies() {
+ // Above in setUpCookies we create an 'old' cookies, wait 10ms, then log a timestamp.
+ // Here we ask the browser to delete all cookies after the timestamp, with the intention
+ // that the 'old' cookie is not removed. The issue arises when the timer precision is
+ // low enough such that the timestamp that gets logged is the same as the 'old' cookie.
+ // We hardcode a precision value to ensure that there is time between the 'old' cookie
+ // and the timestamp generation.
+ Services.prefs.setBoolPref("privacy.reduceTimerPrecision", true);
+ Services.prefs.setIntPref(
+ "privacy.resistFingerprinting.reduceTimerPrecision.microseconds",
+ 2000
+ );
+
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("privacy.reduceTimerPrecision");
+ Services.prefs.clearUserPref(
+ "privacy.resistFingerprinting.reduceTimerPrecision.microseconds"
+ );
+ });
+
+ function background() {
+ browser.test.onMessage.addListener(async (msg, options) => {
+ if (msg == "removeCookies") {
+ await browser.browsingData.removeCookies(options);
+ } else {
+ await browser.browsingData.remove(options, { cookies: true });
+ }
+ browser.test.sendMessage("cookiesRemoved");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["browsingData"],
+ },
+ });
+
+ async function testRemovalMethod(method) {
+ // Clear cookies with a recent since value.
+ await setUpCookies();
+ extension.sendMessage(method, { since });
+ await extension.awaitMessage("cookiesRemoved");
+
+ ok(
+ Services.cookies.cookieExists(
+ oldCookie.host,
+ oldCookie.path,
+ oldCookie.name,
+ {}
+ ),
+ "Old cookie was not removed."
+ );
+ ok(
+ !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}),
+ "Recent cookie was removed."
+ );
+
+ // Clear cookies with an old since value.
+ await setUpCookies();
+ addCookie(COOKIE);
+ extension.sendMessage(method, { since: since - 100000 });
+ await extension.awaitMessage("cookiesRemoved");
+
+ ok(
+ !Services.cookies.cookieExists(
+ oldCookie.host,
+ oldCookie.path,
+ oldCookie.name,
+ {}
+ ),
+ "Old cookie was removed."
+ );
+ ok(
+ !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}),
+ "Recent cookie was removed."
+ );
+
+ // Clear cookies with no since value and valid originTypes.
+ await setUpCookies();
+ extension.sendMessage(method, {
+ originTypes: { unprotectedWeb: true, protectedWeb: false },
+ });
+ await extension.awaitMessage("cookiesRemoved");
+
+ ok(
+ !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}),
+ `Cookie ${COOKIE.name} was removed.`
+ );
+ ok(
+ !Services.cookies.cookieExists(
+ oldCookie.host,
+ oldCookie.path,
+ oldCookie.name,
+ {}
+ ),
+ `Cookie ${oldCookie.name} was removed.`
+ );
+ }
+
+ await extension.startup();
+
+ await testRemovalMethod("removeCookies");
+ await testRemovalMethod("remove");
+
+ await extension.unload();
+});
+
+add_task(async function testCacheAndCookies() {
+ function background() {
+ browser.test.onMessage.addListener(async options => {
+ await browser.browsingData.remove(options, {
+ cache: true,
+ cookies: true,
+ });
+ browser.test.sendMessage("cacheAndCookiesRemoved");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["browsingData"],
+ },
+ });
+
+ await extension.startup();
+
+ // Clear cache and cookies with a recent since value.
+ await setUpCookies();
+ await setUpCache();
+ extension.sendMessage({ since });
+ await extension.awaitMessage("cacheAndCookiesRemoved");
+
+ ok(
+ Services.cookies.cookieExists(
+ oldCookie.host,
+ oldCookie.path,
+ oldCookie.name,
+ {}
+ ),
+ "Old cookie was not removed."
+ );
+ ok(
+ !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}),
+ "Recent cookie was removed."
+ );
+
+ // Cache does not support |since| and deletes everything!
+ ok(!hasCacheEntry("example.net"), "example.net cache was removed");
+ ok(!hasCacheEntry("example.org"), "example.org cache was removed");
+ ok(!hasCacheEntry("example.com"), "example.com cache was removed");
+
+ // Clear cache and cookies with an old since value.
+ await setUpCookies();
+ await setUpCache();
+ extension.sendMessage({ since: since - 100000 });
+ await extension.awaitMessage("cacheAndCookiesRemoved");
+
+ // Cache does not support |since| and deletes everything!
+ ok(!hasCacheEntry("example.net"), "example.net cache was removed");
+ ok(!hasCacheEntry("example.org"), "example.org cache was removed");
+ ok(!hasCacheEntry("example.com"), "example.com cache was removed");
+
+ ok(
+ !Services.cookies.cookieExists(
+ oldCookie.host,
+ oldCookie.path,
+ oldCookie.name,
+ {}
+ ),
+ "Old cookie was removed."
+ );
+ ok(
+ !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}),
+ "Recent cookie was removed."
+ );
+
+ // Clear cache and cookies with hostnames value.
+ await setUpCookies();
+ await setUpCache();
+ extension.sendMessage({
+ hostnames: ["example.net", "example.org", "unknown.com"],
+ });
+ await extension.awaitMessage("cacheAndCookiesRemoved");
+
+ ok(
+ Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}),
+ `Cookie ${COOKIE.name} was not removed.`
+ );
+ ok(
+ !Services.cookies.cookieExists(
+ COOKIE_NET.host,
+ COOKIE_NET.path,
+ COOKIE_NET.name,
+ {}
+ ),
+ `Cookie ${COOKIE_NET.name} was removed.`
+ );
+ ok(
+ !Services.cookies.cookieExists(
+ COOKIE_ORG.host,
+ COOKIE_ORG.path,
+ COOKIE_ORG.name,
+ {}
+ ),
+ `Cookie ${COOKIE_ORG.name} was removed.`
+ );
+
+ ok(!hasCacheEntry("example.net"), "example.net cache was removed");
+ ok(!hasCacheEntry("example.org"), "example.org cache was removed");
+ ok(hasCacheEntry("example.com"), "example.com cache was not removed");
+
+ // Clear cache and cookies with (empty) hostnames value.
+ await setUpCookies();
+ await setUpCache();
+ extension.sendMessage({ hostnames: [] });
+ await extension.awaitMessage("cacheAndCookiesRemoved");
+
+ ok(
+ Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}),
+ `Cookie ${COOKIE.name} was not removed.`
+ );
+ ok(
+ Services.cookies.cookieExists(
+ COOKIE_NET.host,
+ COOKIE_NET.path,
+ COOKIE_NET.name,
+ {}
+ ),
+ `Cookie ${COOKIE_NET.name} was not removed.`
+ );
+ ok(
+ Services.cookies.cookieExists(
+ COOKIE_ORG.host,
+ COOKIE_ORG.path,
+ COOKIE_ORG.name,
+ {}
+ ),
+ `Cookie ${COOKIE_ORG.name} was not removed.`
+ );
+
+ ok(hasCacheEntry("example.net"), "example.net cache was not removed");
+ ok(hasCacheEntry("example.org"), "example.org cache was not removed");
+ ok(hasCacheEntry("example.com"), "example.com cache was not removed");
+
+ // Clear cache and cookies with both hostnames and since values.
+ await setUpCache();
+ await setUpCookies();
+ extension.sendMessage({ hostnames: ["example.com"], since });
+ await extension.awaitMessage("cacheAndCookiesRemoved");
+
+ ok(
+ Services.cookies.cookieExists(
+ oldCookie.host,
+ oldCookie.path,
+ oldCookie.name,
+ {}
+ ),
+ "Old cookie was not removed."
+ );
+ ok(
+ !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}),
+ "Recent cookie was removed."
+ );
+ ok(
+ Services.cookies.cookieExists(
+ COOKIE_NET.host,
+ COOKIE_NET.path,
+ COOKIE_NET.name,
+ {}
+ ),
+ "Cookie with different hostname was not removed"
+ );
+ ok(
+ Services.cookies.cookieExists(
+ COOKIE_ORG.host,
+ COOKIE_ORG.path,
+ COOKIE_ORG.name,
+ {}
+ ),
+ "Cookie with different hostname was not removed"
+ );
+
+ ok(hasCacheEntry("example.net"), "example.net cache was not removed");
+ ok(hasCacheEntry("example.org"), "example.org cache was not removed");
+ ok(!hasCacheEntry("example.com"), "example.com cache was removed");
+
+ // Clear cache and cookies with no since or hostnames value.
+ await setUpCache();
+ await setUpCookies();
+ extension.sendMessage({});
+ await extension.awaitMessage("cacheAndCookiesRemoved");
+
+ ok(
+ !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}),
+ `Cookie ${COOKIE.name} was removed.`
+ );
+ ok(
+ !Services.cookies.cookieExists(
+ oldCookie.host,
+ oldCookie.path,
+ oldCookie.name,
+ {}
+ ),
+ `Cookie ${oldCookie.name} was removed.`
+ );
+ ok(
+ !Services.cookies.cookieExists(
+ COOKIE_NET.host,
+ COOKIE_NET.path,
+ COOKIE_NET.name,
+ {}
+ ),
+ `Cookie ${COOKIE_NET.name} was removed.`
+ );
+ ok(
+ !Services.cookies.cookieExists(
+ COOKIE_ORG.host,
+ COOKIE_ORG.path,
+ COOKIE_ORG.name,
+ {}
+ ),
+ `Cookie ${COOKIE_ORG.name} was removed.`
+ );
+
+ ok(!hasCacheEntry("example.net"), "example.net cache was removed");
+ ok(!hasCacheEntry("example.org"), "example.org cache was removed");
+ ok(!hasCacheEntry("example.com"), "example.com cache was removed");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js
new file mode 100644
index 0000000000..d3d066efd2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js
@@ -0,0 +1,192 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+"use strict";
+
+// "Normal" cookie
+const COOKIE_NORMAL = {
+ host: "example.com",
+ name: "test_cookie",
+ path: "/",
+ originAttributes: {},
+};
+// Private browsing cookie
+const COOKIE_PRIVATE = {
+ host: "example.net",
+ name: "test_cookie",
+ path: "/",
+ originAttributes: {
+ privateBrowsingId: 1,
+ },
+};
+// "firefox-container-1" cookie
+const COOKIE_CONTAINER = {
+ host: "example.org",
+ name: "test_cookie",
+ path: "/",
+ originAttributes: {
+ userContextId: 1,
+ },
+};
+
+function cookieExists(cookie) {
+ return Services.cookies.cookieExists(
+ cookie.host,
+ cookie.path,
+ cookie.name,
+ cookie.originAttributes
+ );
+}
+
+function addCookie(cookie) {
+ const THE_FUTURE = Date.now() + 5 * 60;
+
+ Services.cookies.add(
+ cookie.host,
+ cookie.path,
+ cookie.name,
+ "test",
+ false,
+ false,
+ false,
+ THE_FUTURE,
+ cookie.originAttributes,
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTPS
+ );
+
+ ok(cookieExists(cookie), `Cookie ${cookie.name} was created.`);
+}
+
+async function setUpCookies() {
+ Services.cookies.removeAll();
+
+ addCookie(COOKIE_NORMAL);
+ addCookie(COOKIE_PRIVATE);
+ addCookie(COOKIE_CONTAINER);
+}
+
+add_task(async function testCookies() {
+ Services.prefs.setBoolPref("privacy.userContext.enabled", true);
+
+ function background() {
+ browser.test.onMessage.addListener(async (msg, options) => {
+ if (msg == "removeCookies") {
+ await browser.browsingData.removeCookies(options);
+ } else {
+ await browser.browsingData.remove(options, { cookies: true });
+ }
+ browser.test.sendMessage("cookiesRemoved");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["browsingData"],
+ },
+ });
+
+ async function testRemovalMethod(method) {
+ // Clear only "normal"/default cookies.
+ await setUpCookies();
+
+ extension.sendMessage(method, { cookieStoreId: "firefox-default" });
+ await extension.awaitMessage("cookiesRemoved");
+
+ ok(!cookieExists(COOKIE_NORMAL), "Normal cookie was removed");
+ ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed");
+ ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed");
+
+ // Clear container cookie
+ await setUpCookies();
+
+ extension.sendMessage(method, { cookieStoreId: "firefox-container-1" });
+ await extension.awaitMessage("cookiesRemoved");
+
+ ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed");
+ ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed");
+ ok(!cookieExists(COOKIE_CONTAINER), "Container cookie was removed");
+
+ // Clear private cookie
+ await setUpCookies();
+
+ extension.sendMessage(method, { cookieStoreId: "firefox-private" });
+ await extension.awaitMessage("cookiesRemoved");
+
+ ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed");
+ ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed");
+ ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed");
+
+ // Clear container cookie with correct hostname
+ await setUpCookies();
+
+ extension.sendMessage(method, {
+ cookieStoreId: "firefox-container-1",
+ hostnames: ["example.org"],
+ });
+ await extension.awaitMessage("cookiesRemoved");
+
+ ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed");
+ ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed");
+ ok(!cookieExists(COOKIE_CONTAINER), "Container cookie was removed");
+
+ // Clear container cookie with incorrect hostname; nothing is removed
+ await setUpCookies();
+
+ extension.sendMessage(method, {
+ cookieStoreId: "firefox-container-1",
+ hostnames: ["example.com"],
+ });
+ await extension.awaitMessage("cookiesRemoved");
+
+ ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed");
+ ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed");
+ ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed");
+
+ // Clear private cookie with correct hostname
+ await setUpCookies();
+
+ extension.sendMessage(method, {
+ cookieStoreId: "firefox-private",
+ hostnames: ["example.net"],
+ });
+ await extension.awaitMessage("cookiesRemoved");
+
+ ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed");
+ ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed");
+ ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed");
+
+ // Clear private cookie with incorrect hostname; nothing is removed
+ await setUpCookies();
+
+ extension.sendMessage(method, {
+ cookieStoreId: "firefox-private",
+ hostnames: ["example.com"],
+ });
+ await extension.awaitMessage("cookiesRemoved");
+
+ ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed");
+ ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed");
+ ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed");
+
+ // Clear private cookie by hostname
+ await setUpCookies();
+
+ extension.sendMessage(method, {
+ hostnames: ["example.net"],
+ });
+ await extension.awaitMessage("cookiesRemoved");
+
+ ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed");
+ ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed");
+ ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed");
+ }
+
+ await extension.startup();
+
+ await testRemovalMethod("removeCookies");
+ await testRemovalMethod("remove");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cache_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_cache_api.js
new file mode 100644
index 0000000000..ee00f4de83
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_cache_api.js
@@ -0,0 +1,303 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+const server = createHttpServer({
+ hosts: ["example.com", "anotherdomain.com"],
+});
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("test_ext_cache_api.js");
+});
+
+add_task(async function test_cache_api_http_resource_allowed() {
+ async function background() {
+ try {
+ const BASE_URL = `http://example.com/dummy`;
+
+ const cache = await window.caches.open("test-cache-api");
+ browser.test.assertTrue(
+ await window.caches.has("test-cache-api"),
+ "CacheStorage.has should resolve to true"
+ );
+
+ // Test that adding and requesting cached http urls
+ // works as well.
+ await cache.add(BASE_URL);
+ browser.test.assertEq(
+ "test_ext_cache_api.js",
+ await cache.match(BASE_URL).then(res => res.text()),
+ "Got the expected content from the cached http url"
+ );
+
+ // Test that the http urls that the cache API is allowed
+ // to fetch and cache are limited by the host permissions
+ // associated to the extensions (same as when the extension
+ // for fetch from those urls using fetch or XHR).
+ await browser.test.assertRejects(
+ cache.add(`http://anotherdomain.com/dummy`),
+ "NetworkError when attempting to fetch resource.",
+ "Got the expected rejection of requesting an http not allowed by host permissions"
+ );
+
+ // Test that deleting the cache storage works as expected.
+ browser.test.assertTrue(
+ await window.caches.delete("test-cache-api"),
+ "Cache deleted successfully"
+ );
+ browser.test.assertTrue(
+ !(await window.caches.has("test-cache-api")),
+ "CacheStorage.has should resolve to false"
+ );
+ } catch (err) {
+ browser.test.fail(`Unexpected error on using Cache API: ${err}`);
+ throw err;
+ } finally {
+ browser.test.sendMessage("test-cache-api-allowed");
+ }
+ }
+
+ // Verify that Cache API support for http urls is available
+ // regardless of extensions.backgroundServiceWorker.enabled pref.
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: { permissions: ["http://example.com/*"] },
+ background,
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("test-cache-api-allowed");
+ await extension.unload();
+});
+
+// This test is similar to `test_cache_api_http_resource_allowed` but it does
+// exercise the Cache API from a moz-extension shared worker.
+// We expect the cache API calls to be successfull when it is being used to
+// cache an HTTP url that is allowed for the extensions based on its host
+// permission, but to fail if the extension doesn't have the required host
+// permission to fetch data from that url.
+add_task(async function test_cache_api_from_ext_shared_worker() {
+ if (!WebExtensionPolicy.useRemoteWebExtensions) {
+ // Ensure RemoteWorkerService has been initialized in the main
+ // process.
+ Services.obs.notifyObservers(null, "profile-after-change");
+ }
+
+ const background = async function () {
+ const BASE_URL_OK = `http://example.com/dummy`;
+ const BASE_URL_KO = `http://anotherdomain.com/dummy`;
+ const worker = new SharedWorker("worker.js");
+ const { data: resultOK } = await new Promise(resolve => {
+ worker.port.onmessage = resolve;
+ worker.port.postMessage(["worker-cacheapi-test-allowed", BASE_URL_OK]);
+ });
+ browser.test.log(
+ `Got result from extension worker for allowed host url: ${JSON.stringify(
+ resultOK
+ )}`
+ );
+ const { data: resultKO } = await new Promise(resolve => {
+ worker.port.onmessage = resolve;
+ worker.port.postMessage(["worker-cacheapi-test-disallowed", BASE_URL_KO]);
+ });
+ browser.test.log(
+ `Got result from extension worker for disallowed host url: ${JSON.stringify(
+ resultKO
+ )}`
+ );
+
+ browser.test.assertTrue(
+ await window.caches.has("test-cache-api"),
+ "CacheStorage.has should resolve to true"
+ );
+ const cache = await window.caches.open("test-cache-api");
+ browser.test.assertEq(
+ "test_ext_cache_api.js",
+ await cache.match(BASE_URL_OK).then(res => res.text()),
+ "Got the expected content from the cached http url"
+ );
+ browser.test.assertEq(
+ true,
+ await cache.match(BASE_URL_KO).then(res => res == undefined),
+ "Got no match for the http url that isn't allowed by host permissions"
+ );
+
+ browser.test.sendMessage("test-cacheapi-sharedworker:done", {
+ expectAllowed: resultOK,
+ expectDisallowed: resultKO,
+ });
+ };
+
+ const extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: { permissions: ["http://example.com/*"] },
+ files: {
+ "worker.js": function () {
+ self.onconnect = evt => {
+ const port = evt.ports[0];
+ port.onmessage = async evt => {
+ let result = {};
+ let message;
+ try {
+ const [msg, BASE_URL] = evt.data;
+ message = msg;
+ const cache = await self.caches.open("test-cache-api");
+ await cache.add(BASE_URL);
+ result.success = true;
+ } catch (err) {
+ result.error = err.message;
+ throw err;
+ } finally {
+ port.postMessage([`${message}:result`, result]);
+ }
+ };
+ };
+ },
+ },
+ });
+
+ await extension.startup();
+ const { expectAllowed, expectDisallowed } = await extension.awaitMessage(
+ "test-cacheapi-sharedworker:done"
+ );
+ // Increase the chance to have the error message related to an unexpected
+ // failure to be explicitly mention in the failure message.
+ Assert.deepEqual(
+ expectAllowed,
+ ["worker-cacheapi-test-allowed:result", { success: true }],
+ "Expect worker result to be successfull with the required host permission"
+ );
+ Assert.deepEqual(
+ expectDisallowed,
+ [
+ "worker-cacheapi-test-disallowed:result",
+ { error: "NetworkError when attempting to fetch resource." },
+ ],
+ "Expect worker result to be unsuccessfull without the required host permission"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_cache_storage_evicted_on_addon_uninstalled() {
+ async function background() {
+ try {
+ const BASE_URL = `http://example.com/dummy`;
+
+ const cache = await window.caches.open("test-cache-api");
+ browser.test.assertTrue(
+ await window.caches.has("test-cache-api"),
+ "CacheStorage.has should resolve to true"
+ );
+
+ // Test that adding and requesting cached http urls
+ // works as well.
+ await cache.add(BASE_URL);
+ browser.test.assertEq(
+ "test_ext_cache_api.js",
+ await cache.match(BASE_URL).then(res => res.text()),
+ "Got the expected content from the cached http url"
+ );
+ } catch (err) {
+ browser.test.fail(`Unexpected error on using Cache API: ${err}`);
+ throw err;
+ } finally {
+ browser.test.sendMessage("cache-storage-created");
+ }
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: { permissions: ["http://example.com/*"] },
+ background,
+ // Necessary to be sure the expected extension stored data cleanup callback
+ // will be called when the extension is uninstalled from an AddonManager
+ // perspective.
+ useAddonManager: "temporary",
+ });
+
+ await AddonTestUtils.promiseStartupManager();
+ await extension.startup();
+ await extension.awaitMessage("cache-storage-created");
+
+ const extURL = `moz-extension://${extension.extension.uuid}`;
+ const extPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(extURL),
+ {}
+ );
+ let extCacheStorage = new CacheStorage("content", extPrincipal);
+
+ ok(
+ await extCacheStorage.has("test-cache-api"),
+ "Got the expected extension cache storage"
+ );
+
+ await extension.unload();
+
+ ok(
+ !(await extCacheStorage.has("test-cache-api")),
+ "The extension cache storage data should have been evicted on addon uninstall"
+ );
+});
+
+add_task(
+ {
+ // Pref used to allow to use the Cache WebAPI related to a page loaded from http
+ // (otherwise Gecko will throw a SecurityError when trying to access the webpage
+ // cache storage from the content script, unless the webpage is loaded from https).
+ pref_set: [["dom.caches.testing.enabled", true]],
+ },
+ async function test_cache_put_from_contentscript() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/*"],
+ js: ["content.js"],
+ },
+ ],
+ },
+ files: {
+ "content.js": async function () {
+ const cache = await caches.open("test-cachestorage");
+ const request = "http://example.com";
+ const response = await fetch(request);
+ await cache.put(request, response).catch(err => {
+ browser.test.sendMessage("cache-put-error", {
+ name: err.name,
+ message: err.message,
+ });
+ });
+ },
+ },
+ });
+
+ await extension.startup();
+
+ const page = await ExtensionTestUtils.loadContentPage("http://example.com");
+ const actualError = await extension.awaitMessage("cache-put-error");
+ equal(
+ actualError.name,
+ "SecurityError",
+ "Got a security error from cache.put call as expected"
+ );
+ ok(
+ /Disallowed on WebExtension ContentScript Request/.test(
+ actualError.message
+ ),
+ `Got the expected error message: ${actualError.message}`
+ );
+
+ await page.close();
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js
new file mode 100644
index 0000000000..dfb5c4c415
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js
@@ -0,0 +1,202 @@
+"use strict";
+
+Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ true
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+/**
+ * This duplicates the test from netwerk/test/unit/test_captive_portal_service.js
+ * however using an extension to gather the captive portal information.
+ */
+
+const PREF_CAPTIVE_ENABLED = "network.captive-portal-service.enabled";
+const PREF_CAPTIVE_TESTMODE = "network.captive-portal-service.testMode";
+const PREF_CAPTIVE_MINTIME = "network.captive-portal-service.minInterval";
+const PREF_CAPTIVE_ENDPOINT = "captivedetect.canonicalURL";
+const PREF_DNS_NATIVE_IS_LOCALHOST = "network.dns.native-is-localhost";
+
+const SUCCESS_STRING =
+ '<meta http-equiv="refresh" content="0;url=https://support.mozilla.org/kb/captive-portal"/>';
+let cpResponse = SUCCESS_STRING;
+
+const httpserver = createHttpServer();
+httpserver.registerPathHandler("/captive.txt", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(cpResponse);
+});
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(PREF_CAPTIVE_ENABLED);
+ Services.prefs.clearUserPref(PREF_CAPTIVE_TESTMODE);
+ Services.prefs.clearUserPref(PREF_CAPTIVE_ENDPOINT);
+ Services.prefs.clearUserPref(PREF_CAPTIVE_MINTIME);
+ Services.prefs.clearUserPref(PREF_DNS_NATIVE_IS_LOCALHOST);
+});
+
+add_task(async function setup() {
+ Services.prefs.setCharPref(
+ PREF_CAPTIVE_ENDPOINT,
+ `http://localhost:${httpserver.identity.primaryPort}/captive.txt`
+ );
+ Services.prefs.setBoolPref(PREF_CAPTIVE_TESTMODE, true);
+ Services.prefs.setIntPref(PREF_CAPTIVE_MINTIME, 0);
+ Services.prefs.setBoolPref(PREF_DNS_NATIVE_IS_LOCALHOST, true);
+
+ Services.prefs.setBoolPref("extensions.eventPages.enabled", true);
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_captivePortal_basic() {
+ let cps = Cc["@mozilla.org/network/captive-portal-service;1"].getService(
+ Ci.nsICaptivePortalService
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["captivePortal"],
+ background: { persistent: false },
+ },
+ isPrivileged: true,
+ async background() {
+ browser.captivePortal.onConnectivityAvailable.addListener(details => {
+ browser.test.log(
+ `onConnectivityAvailable received ${JSON.stringify(details)}`
+ );
+ browser.test.sendMessage("connectivity", details);
+ });
+
+ browser.captivePortal.onStateChanged.addListener(details => {
+ browser.test.log(`onStateChanged received ${JSON.stringify(details)}`);
+ browser.test.sendMessage("state", details);
+ });
+
+ browser.captivePortal.canonicalURL.onChange.addListener(details => {
+ browser.test.sendMessage("url", details);
+ });
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg == "getstate") {
+ browser.test.sendMessage(
+ "getstate",
+ await browser.captivePortal.getState()
+ );
+ }
+ });
+ },
+ });
+ await extension.startup();
+
+ extension.sendMessage("getstate");
+ let details = await extension.awaitMessage("getstate");
+ equal(details, "unknown", "initial state");
+
+ // The captive portal service is started by nsIOService when the pref becomes true, so we
+ // toggle the pref. We cannot set to false before the extension loads above.
+ Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, false);
+ Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, true);
+
+ details = await extension.awaitMessage("connectivity");
+ equal(details.status, "clear", "initial connectivity");
+ extension.sendMessage("getstate");
+ details = await extension.awaitMessage("getstate");
+ equal(details, "not_captive", "initial state");
+
+ info("REFRESH to other");
+ cpResponse = "other";
+ cps.recheckCaptivePortal();
+ details = await extension.awaitMessage("state");
+ equal(details.state, "locked_portal", "state in portal");
+
+ info("REFRESH to success");
+ cpResponse = SUCCESS_STRING;
+ cps.recheckCaptivePortal();
+ details = await extension.awaitMessage("connectivity");
+ equal(details.status, "captive", "final connectivity");
+
+ details = await extension.awaitMessage("state");
+ equal(details.state, "unlocked_portal", "state after unlocking portal");
+
+ assertPersistentListeners(
+ extension,
+ "captivePortal",
+ "onConnectivityAvailable",
+ {
+ primed: false,
+ }
+ );
+
+ assertPersistentListeners(extension, "captivePortal", "onStateChanged", {
+ primed: false,
+ });
+
+ assertPersistentListeners(extension, "captivePortal", "captiveURL.onChange", {
+ primed: false,
+ });
+
+ info("Test event page terminate/waken");
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ ok(
+ !extension.extension.backgroundContext,
+ "Background Extension context should have been destroyed"
+ );
+
+ assertPersistentListeners(extension, "captivePortal", "onStateChanged", {
+ primed: true,
+ });
+ assertPersistentListeners(
+ extension,
+ "captivePortal",
+ "onConnectivityAvailable",
+ {
+ primed: true,
+ }
+ );
+
+ assertPersistentListeners(extension, "captivePortal", "captiveURL.onChange", {
+ primed: true,
+ });
+
+ info("REFRESH 2nd pass to other");
+ cpResponse = "other";
+ cps.recheckCaptivePortal();
+ details = await extension.awaitMessage("state");
+ equal(details.state, "locked_portal", "state in portal");
+
+ info("Test event page terminate/waken with settings");
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ ok(
+ !extension.extension.backgroundContext,
+ "Background Extension context should have been destroyed"
+ );
+
+ assertPersistentListeners(extension, "captivePortal", "captiveURL.onChange", {
+ primed: true,
+ });
+
+ Services.prefs.setStringPref(
+ "captivedetect.canonicalURL",
+ "http://example.com"
+ );
+ let url = await extension.awaitMessage("url");
+ equal(
+ url.value,
+ "http://example.com",
+ "The canonicalURL setting has the expected value."
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js
new file mode 100644
index 0000000000..7bd83b0572
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.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_url_get_without_set() {
+ async function background() {
+ browser.captivePortal.canonicalURL.onChange.addListener(details => {
+ browser.test.sendMessage("url", details);
+ });
+ let url = await browser.captivePortal.canonicalURL.get({});
+ browser.test.sendMessage("url", url);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["captivePortal"],
+ },
+ });
+
+ let defaultURL = Services.prefs.getStringPref("captivedetect.canonicalURL");
+
+ await extension.startup();
+ let url = await extension.awaitMessage("url");
+ equal(
+ url.value,
+ defaultURL,
+ "The canonicalURL setting has the expected value."
+ );
+ equal(
+ url.levelOfControl,
+ "not_controllable",
+ "The canonicalURL setting has the expected levelOfControl."
+ );
+
+ Services.prefs.setStringPref(
+ "captivedetect.canonicalURL",
+ "http://example.com"
+ );
+ url = await extension.awaitMessage("url");
+ equal(
+ url.value,
+ "http://example.com",
+ "The canonicalURL setting has the expected value."
+ );
+ equal(
+ url.levelOfControl,
+ "not_controllable",
+ "The canonicalURL setting has the expected levelOfControl."
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_clear_cached_resources.js b/toolkit/components/extensions/test/xpcshell/test_ext_clear_cached_resources.js
new file mode 100644
index 0000000000..e4f9f1d40b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_clear_cached_resources.js
@@ -0,0 +1,417 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+Services.prefs.setBoolPref("extensions.blocklist.enabled", false);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall";
+
+const BASE64_R_PIXEL =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P4z8DwHwAFAAH/F1FwBgAAAABJRU5ErkJggg==";
+const BASE64_G_PIXEL =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2Ng+M/wHwAEAQH/7yMK/gAAAABJRU5ErkJggg==";
+const BASE64_B_PIXEL =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgYPj/HwADAgH/eL9GtQAAAABJRU5ErkJggg==";
+
+const toArrayBuffer = b64data =>
+ Uint8Array.from(atob(b64data), c => c.charCodeAt(0));
+const IMAGE_RED = toArrayBuffer(BASE64_R_PIXEL).buffer;
+const IMAGE_GREEN = toArrayBuffer(BASE64_G_PIXEL).buffer;
+const IMAGE_BLUE = toArrayBuffer(BASE64_B_PIXEL).buffer;
+
+const RGB_RED = "rgb(255, 0, 0)";
+const RGB_GREEN = "rgb(0, 255, 0)";
+const RGB_BLUE = "rgb(0, 0, 255)";
+
+const CSS_RED_BG = `body { background-color: ${RGB_RED}; }`;
+const CSS_GREEN_BG = `body { background-color: ${RGB_GREEN}; }`;
+const CSS_BLUE_BG = `body { background-color: ${RGB_BLUE}; }`;
+
+const ADDON_ID = "test-cached-resources@test";
+
+const manifest = {
+ version: "1",
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+};
+
+const files = {
+ "extpage.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <link rel="stylesheet" href="extpage.css">
+ </head>
+ <body>
+ <img id="test-image" src="image.png">
+ </body>
+ </html>
+ `,
+ "other_extpage.html": `<!DOCTYPE html>
+ <html>
+ <body>
+ </body>
+ </html>
+ `,
+ "extpage.css": CSS_RED_BG,
+ "image.png": IMAGE_RED,
+};
+
+const getBackgroundColor = () => {
+ return this.content.getComputedStyle(this.content.document.body)
+ .backgroundColor;
+};
+
+const hasCachedImage = imgUrl => {
+ const { document } = this.content;
+
+ const imageCache = Cc["@mozilla.org/image/tools;1"]
+ .getService(Ci.imgITools)
+ .getImgCacheForDocument(document);
+
+ const imgCacheProps = imageCache.findEntryProperties(
+ Services.io.newURI(imgUrl),
+ document
+ );
+
+ // return true if the image was in the cache.
+ return !!imgCacheProps;
+};
+
+const getImageColor = () => {
+ const { document } = this.content;
+ const img = document.querySelector("img#test-image");
+ const canvas = document.createElement("canvas");
+ canvas.width = 1;
+ canvas.height = 1;
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(img, 0, 0); // Draw without scaling.
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
+ if (a < 1) {
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+ }
+ return `rgb(${r}, ${g}, ${b})`;
+};
+
+async function assertBackgroundColor(page, color, message) {
+ equal(
+ await page.spawn([], getBackgroundColor),
+ color,
+ `Got the expected ${message}`
+ );
+}
+
+async function assertImageColor(page, color, message) {
+ equal(await page.spawn([], getImageColor), color, message);
+}
+
+async function assertImageCached(page, imageUrl, message) {
+ ok(await page.spawn([imageUrl], hasCachedImage), message);
+}
+
+// This test verifies that cached css are cleared across addon upgrades and downgrades
+// for permanently installed addon (See Bug 1746841).
+add_task(async function test_cached_resources_cleared_across_addon_updates() {
+ await AddonTestUtils.promiseStartupManager();
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest,
+ files,
+ });
+
+ await extension.startup();
+ equal(
+ extension.version,
+ "1",
+ "Got the expected version for the initial extension"
+ );
+
+ const url = extension.extension.baseURI.resolve("extpage.html");
+ let page = await ExtensionTestUtils.loadContentPage(url);
+ await assertBackgroundColor(
+ page,
+ RGB_RED,
+ "background color (initial extension version)"
+ );
+ await assertImageColor(page, RGB_RED, "image (initial extension version)");
+
+ info("Verify extension page css and image after addon upgrade");
+
+ await extension.upgrade({
+ useAddonManager: "permanent",
+ manifest: {
+ ...manifest,
+ version: "2",
+ },
+ files: {
+ ...files,
+ "extpage.css": CSS_GREEN_BG,
+ "image.png": IMAGE_GREEN,
+ },
+ });
+ equal(
+ extension.version,
+ "2",
+ "Got the expected version for the upgraded extension"
+ );
+
+ await page.loadURL(url);
+
+ await assertBackgroundColor(
+ page,
+ RGB_GREEN,
+ "background color (upgraded extension version)"
+ );
+ await assertImageColor(page, RGB_GREEN, "image (upgraded extension version)");
+
+ info("Verify extension page css and image after addon downgrade");
+
+ await extension.upgrade({
+ useAddonManager: "permanent",
+ manifest,
+ files,
+ });
+ equal(
+ extension.version,
+ "1",
+ "Got the expected version for the downgraded extension"
+ );
+
+ await page.loadURL(url);
+
+ await assertBackgroundColor(
+ page,
+ RGB_RED,
+ "background color (downgraded extension version)"
+ );
+ await assertImageColor(
+ page,
+ RGB_RED,
+ "image color (downgraded extension version)"
+ );
+
+ await page.close();
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// This test verifies that cached css are cleared if we are installing a new
+// extension and we did not clear the cache for a previous one with the same uuid
+// when it was uninstalled (See Bug 1746841).
+add_task(async function test_cached_resources_cleared_on_addon_install() {
+ // Make sure the test addon installed without an AddonManager addon wrapper
+ // and the ones installed right after that using the AddonManager will share
+ // the same uuid (and so also the same moz-extension resource urls).
+ Services.prefs.setBoolPref(LEAVE_UUID_PREF, true);
+ registerCleanupFunction(() => Services.prefs.clearUserPref(LEAVE_UUID_PREF));
+
+ await AddonTestUtils.promiseStartupManager();
+
+ const nonAOMExtension = ExtensionTestUtils.loadExtension({
+ manifest,
+ files: {
+ ...files,
+ // Override css with a different color from the one expected
+ // later in this test case.
+ "extpage.css": CSS_BLUE_BG,
+ "image.png": IMAGE_BLUE,
+ },
+ });
+
+ await nonAOMExtension.startup();
+ equal(
+ await AddonManager.getAddonByID(ADDON_ID),
+ null,
+ "No AOM addon wrapper found as expected"
+ );
+ let url = nonAOMExtension.extension.baseURI.resolve("extpage.html");
+ let page = await ExtensionTestUtils.loadContentPage(url);
+ await assertBackgroundColor(
+ page,
+ RGB_BLUE,
+ "background color (addon installed without uninstall observer)"
+ );
+ await assertImageColor(
+ page,
+ RGB_BLUE,
+ "image (addon uninstalled without clearing cache)"
+ );
+
+ // NOTE: unloading a test extension that does not have an AddonManager addon wrapper
+ // does not trigger the uninstall observer, and this is what this test needs to make
+ // sure that if the cached resources were not cleared on uninstall, then we will still
+ // clear it when a newly installed addon is installed even if the two extensions
+ // are sharing the same addon uuid (and so also the same moz-extension resource urls).
+ await nonAOMExtension.unload();
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest,
+ files,
+ });
+
+ await extension.startup();
+ await page.loadURL(url);
+
+ await assertBackgroundColor(
+ page,
+ RGB_RED,
+ "background color (newly installed addon, same addon id)"
+ );
+ await assertImageColor(
+ page,
+ RGB_RED,
+ "image (newly installed addon, same addon id)"
+ );
+
+ await page.close();
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// This test verifies that reloading a temporarily installed addon after
+// changing a css file cached in a previous run clears the previously
+// cached css and uses the new one changed on disk (See Bug 1746841).
+add_task(
+ async function test_cached_resources_cleared_on_temporary_addon_reload() {
+ await AddonTestUtils.promiseStartupManager();
+
+ const xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest,
+ files,
+ });
+
+ // This temporary directory is going to be removed from the
+ // cleanup function, but also make it unique as we do for the
+ // other temporary files (e.g. like getTemporaryFile as defined
+ // in XPInstall.jsm).
+ const random = Math.round(Math.random() * 36 ** 3).toString(36);
+ const tmpDirName = `xpcshelltest_unpacked_addons_${random}`;
+ let tmpExtPath = FileUtils.getDir("TmpD", [tmpDirName], true);
+ registerCleanupFunction(() => {
+ tmpExtPath.remove(true);
+ });
+
+ // Unpacking the xpi file into the temporary directory.
+ const extDir = await AddonTestUtils.manuallyInstall(
+ xpi,
+ tmpExtPath,
+ null,
+ /* unpacked */ true
+ );
+
+ let extension = ExtensionTestUtils.expectExtension(ADDON_ID);
+ await AddonManager.installTemporaryAddon(extDir);
+ await extension.awaitStartup();
+
+ equal(
+ extension.version,
+ "1",
+ "Got the expected version for the initial extension"
+ );
+
+ const url = extension.extension.baseURI.resolve("extpage.html");
+ let page = await ExtensionTestUtils.loadContentPage(url);
+ await assertBackgroundColor(
+ page,
+ RGB_RED,
+ "background color (initial extension version)"
+ );
+ await assertImageColor(page, RGB_RED, "image (initial extension version)");
+
+ info("Verify updated extension page css and image after addon reload");
+
+ const targetCSSFile = extDir.clone();
+ targetCSSFile.append("extpage.css");
+ ok(
+ targetCSSFile.exists(),
+ `Found the ${targetCSSFile.path} target file on disk`
+ );
+ await IOUtils.writeUTF8(targetCSSFile.path, CSS_GREEN_BG);
+
+ const targetPNGFile = extDir.clone();
+ targetPNGFile.append("image.png");
+ ok(
+ targetPNGFile.exists(),
+ `Found the ${targetPNGFile.path} target file on disk`
+ );
+ await IOUtils.write(targetPNGFile.path, toArrayBuffer(BASE64_G_PIXEL));
+
+ const addon = await AddonManager.getAddonByID(ADDON_ID);
+ ok(addon, "Got an AddonWrapper for the test extension");
+ await addon.reload();
+
+ await page.loadURL(url);
+
+ await assertBackgroundColor(
+ page,
+ RGB_GREEN,
+ "background (updated files on disk)"
+ );
+ await assertImageColor(page, RGB_GREEN, "image (updated files on disk)");
+
+ await page.close();
+ await addon.uninstall();
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
+
+// This test verifies that cached images are not cleared between
+// permanently installed addon reloads.
+add_task(async function test_cached_image_kept_on_permanent_addon_restarts() {
+ await AddonTestUtils.promiseStartupManager();
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest,
+ files,
+ });
+
+ await extension.startup();
+
+ equal(
+ extension.version,
+ "1",
+ "Got the expected version for the initial extension"
+ );
+
+ const imageUrl = extension.extension.baseURI.resolve("image.png");
+ const url = extension.extension.baseURI.resolve("extpage.html");
+
+ let page = await ExtensionTestUtils.loadContentPage(url);
+ await assertBackgroundColor(
+ page,
+ RGB_RED,
+ "background color (first startup)"
+ );
+ await assertImageColor(page, RGB_RED, "image (first startup)");
+ await assertImageCached(page, imageUrl, "image cached (first startup)");
+
+ info("Reload the AddonManager to simulate browser restart");
+ extension.setRestarting();
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+
+ await page.loadURL(extension.extension.baseURI.resolve("other_extpage.html"));
+ await assertImageCached(
+ page,
+ imageUrl,
+ "image still cached after AddonManager restart"
+ );
+
+ await page.close();
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js
new file mode 100644
index 0000000000..c92ed11022
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js
@@ -0,0 +1,808 @@
+"use strict";
+
+const { createAppInfo } = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+function check_applied_styles() {
+ const urlElStyle = getComputedStyle(
+ document.querySelector("#registered-extension-url-style")
+ );
+ const blobElStyle = getComputedStyle(
+ document.querySelector("#registered-extension-text-style")
+ );
+
+ browser.test.sendMessage("registered-styles-results", {
+ registeredExtensionUrlStyleBG: urlElStyle["background-color"],
+ registeredExtensionBlobStyleBG: blobElStyle["background-color"],
+ });
+}
+
+add_task(async function test_contentscripts_register_css() {
+ async function background() {
+ let cssCode = `
+ #registered-extension-text-style {
+ background-color: blue;
+ }
+ `;
+
+ const matches = ["http://localhost/*/file_sample_registered_styles.html"];
+
+ browser.test.assertThrows(
+ () => {
+ browser.contentScripts.register({
+ matches,
+ unknownParam: "unexpected property",
+ });
+ },
+ /Unexpected property "unknownParam"/,
+ "contentScripts.register throws on unexpected properties"
+ );
+
+ let fileScript = await browser.contentScripts.register({
+ css: [{ file: "registered_ext_style.css" }],
+ matches,
+ runAt: "document_start",
+ });
+
+ let textScript = await browser.contentScripts.register({
+ css: [{ code: cssCode }],
+ matches,
+ runAt: "document_start",
+ });
+
+ browser.test.onMessage.addListener(async msg => {
+ switch (msg) {
+ case "unregister-text":
+ await textScript.unregister().catch(err => {
+ browser.test.fail(
+ `Unexpected exception while unregistering text style: ${err}`
+ );
+ });
+
+ await browser.test.assertRejects(
+ textScript.unregister(),
+ /Content script already unregistered/,
+ "Got the expected rejection on calling script.unregister() multiple times"
+ );
+
+ browser.test.sendMessage("unregister-text:done");
+ break;
+ case "unregister-file":
+ await fileScript.unregister().catch(err => {
+ browser.test.fail(
+ `Unexpected exception while unregistering url style: ${err}`
+ );
+ });
+
+ await browser.test.assertRejects(
+ fileScript.unregister(),
+ /Content script already unregistered/,
+ "Got the expected rejection on calling script.unregister() multiple times"
+ );
+
+ browser.test.sendMessage("unregister-file:done");
+ break;
+ default:
+ browser.test.fail(`Unexpected test message received: ${msg}`);
+ }
+ });
+
+ browser.test.sendMessage("background_ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "http://localhost/*/file_sample_registered_styles.html",
+ "<all_urls>",
+ ],
+ content_scripts: [
+ {
+ matches: ["http://localhost/*/file_sample_registered_styles.html"],
+ run_at: "document_idle",
+ js: ["check_applied_styles.js"],
+ },
+ ],
+ },
+ background,
+
+ files: {
+ "check_applied_styles.js": check_applied_styles,
+ "registered_ext_style.css": `
+ #registered-extension-url-style {
+ background-color: red;
+ }
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("background_ready");
+
+ // Ensure that a content page running in a content process and which has been
+ // started after the content scripts has been registered, it still receives
+ // and registers the expected content scripts.
+ let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+
+ await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+ const registeredStylesResults = await extension.awaitMessage(
+ "registered-styles-results"
+ );
+
+ equal(
+ registeredStylesResults.registeredExtensionUrlStyleBG,
+ "rgb(255, 0, 0)",
+ "The expected style has been applied from the registered extension url style"
+ );
+ equal(
+ registeredStylesResults.registeredExtensionBlobStyleBG,
+ "rgb(0, 0, 255)",
+ "The expected style has been applied from the registered extension blob style"
+ );
+
+ extension.sendMessage("unregister-file");
+ await extension.awaitMessage("unregister-file:done");
+
+ await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+ const unregisteredURLStylesResults = await extension.awaitMessage(
+ "registered-styles-results"
+ );
+
+ equal(
+ unregisteredURLStylesResults.registeredExtensionUrlStyleBG,
+ "rgba(0, 0, 0, 0)",
+ "The expected style has been applied once extension url style has been unregistered"
+ );
+ equal(
+ unregisteredURLStylesResults.registeredExtensionBlobStyleBG,
+ "rgb(0, 0, 255)",
+ "The expected style has been applied from the registered extension blob style"
+ );
+
+ extension.sendMessage("unregister-text");
+ await extension.awaitMessage("unregister-text:done");
+
+ await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+ const unregisteredBlobStylesResults = await extension.awaitMessage(
+ "registered-styles-results"
+ );
+
+ equal(
+ unregisteredBlobStylesResults.registeredExtensionUrlStyleBG,
+ "rgba(0, 0, 0, 0)",
+ "The expected style has been applied once extension url style has been unregistered"
+ );
+ equal(
+ unregisteredBlobStylesResults.registeredExtensionBlobStyleBG,
+ "rgba(0, 0, 0, 0)",
+ "The expected style has been applied once extension blob style has been unregistered"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_contentscripts_unregister_on_context_unload() {
+ async function background() {
+ const frame = document.createElement("iframe");
+ frame.setAttribute("src", "/background-frame.html");
+
+ document.body.appendChild(frame);
+
+ browser.test.onMessage.addListener(msg => {
+ switch (msg) {
+ case "unload-frame":
+ frame.remove();
+ browser.test.sendMessage("unload-frame:done");
+ break;
+ default:
+ browser.test.fail(`Unexpected test message received: ${msg}`);
+ }
+ });
+
+ browser.test.sendMessage("background_ready");
+ }
+
+ async function background_frame() {
+ await browser.contentScripts.register({
+ css: [{ file: "registered_ext_style.css" }],
+ matches: ["http://localhost/*/file_sample_registered_styles.html"],
+ runAt: "document_start",
+ });
+
+ browser.test.sendMessage("background_frame_ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://localhost/*/file_sample_registered_styles.html"],
+ content_scripts: [
+ {
+ matches: ["http://localhost/*/file_sample_registered_styles.html"],
+ run_at: "document_idle",
+ js: ["check_applied_styles.js"],
+ },
+ ],
+ },
+ background,
+
+ files: {
+ "background-frame.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <script src="background-frame.js"></script>
+ </head>
+ <body>
+ </body>
+ </html>
+ `,
+ "background-frame.js": background_frame,
+ "check_applied_styles.js": check_applied_styles,
+ "registered_ext_style.css": `
+ #registered-extension-url-style {
+ background-color: red;
+ }
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("background_ready");
+
+ // Wait the background frame to have been loaded and its script
+ // executed.
+ await extension.awaitMessage("background_frame_ready");
+
+ // Ensure that a content page running in a content process and which has been
+ // started after the content scripts has been registered, it still receives
+ // and registers the expected content scripts.
+ let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+
+ await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+ const registeredStylesResults = await extension.awaitMessage(
+ "registered-styles-results"
+ );
+
+ equal(
+ registeredStylesResults.registeredExtensionUrlStyleBG,
+ "rgb(255, 0, 0)",
+ "The expected style has been applied from the registered extension url style"
+ );
+
+ extension.sendMessage("unload-frame");
+ await extension.awaitMessage("unload-frame:done");
+
+ await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+ const unregisteredURLStylesResults = await extension.awaitMessage(
+ "registered-styles-results"
+ );
+
+ equal(
+ unregisteredURLStylesResults.registeredExtensionUrlStyleBG,
+ "rgba(0, 0, 0, 0)",
+ "The expected style has been applied once extension url style has been unregistered"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_contentscripts_register_js() {
+ async function background() {
+ browser.runtime.onMessage.addListener(
+ ([msg, expectedStates, readyState], sender) => {
+ if (msg == "chrome-namespace-ok") {
+ browser.test.sendMessage(msg);
+ return;
+ }
+
+ browser.test.assertEq("script-run", msg, "message type is correct");
+ browser.test.assertTrue(
+ expectedStates.includes(readyState),
+ `readyState "${readyState}" is one of [${expectedStates}]`
+ );
+ browser.test.sendMessage("script-run-" + expectedStates[0]);
+ }
+ );
+
+ // Raise an exception when the content script cannot be registered
+ // because the extension has no permission to access the specified origin.
+
+ await browser.test.assertRejects(
+ browser.contentScripts.register({
+ matches: ["http://*/*"],
+ js: [
+ {
+ code: 'browser.test.fail("content script with wrong matches should not run")',
+ },
+ ],
+ }),
+ /Permission denied to register a content script for/,
+ "The reject contains the expected error message"
+ );
+
+ // Register a content script from a JS code string.
+
+ function textScriptCodeStart() {
+ browser.runtime.sendMessage([
+ "script-run",
+ ["loading"],
+ document.readyState,
+ ]);
+ }
+ function textScriptCodeEnd() {
+ browser.runtime.sendMessage([
+ "script-run",
+ ["interactive", "complete"],
+ document.readyState,
+ ]);
+ }
+ function textScriptCodeIdle() {
+ browser.runtime.sendMessage([
+ "script-run",
+ ["complete"],
+ document.readyState,
+ ]);
+ }
+
+ // Register content scripts from both extension URLs and plain JS code strings.
+
+ const content_scripts = [
+ // Plain JS code strings.
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{ code: `(${textScriptCodeStart})()` }],
+ runAt: "document_start",
+ },
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{ code: `(${textScriptCodeEnd})()` }],
+ runAt: "document_end",
+ },
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{ code: `(${textScriptCodeIdle})()` }],
+ runAt: "document_idle",
+ },
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{ code: `(${textScriptCodeIdle})()` }],
+ runAt: "document_idle",
+ cookieStoreId: "firefox-container-1",
+ },
+ // Extension URLs.
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{ file: "content_script_start.js" }],
+ runAt: "document_start",
+ },
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{ file: "content_script_end.js" }],
+ runAt: "document_end",
+ },
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{ file: "content_script_idle.js" }],
+ runAt: "document_idle",
+ },
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{ file: "content_script.js" }],
+ // "runAt" is not specified here to ensure that it defaults to document_idle when missing.
+ },
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{ file: "content_script_idle.js" }],
+ runAt: "document_idle",
+ cookieStoreId: "firefox-container-1",
+ },
+ ];
+
+ const expectedAPIs = ["unregister"];
+
+ for (const scriptOptions of content_scripts) {
+ const script = await browser.contentScripts.register(scriptOptions);
+ const actualAPIs = Object.keys(script);
+
+ browser.test.assertEq(
+ JSON.stringify(expectedAPIs),
+ JSON.stringify(actualAPIs),
+ `Got a script API object for ${scriptOptions.js[0]}`
+ );
+ }
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ function contentScriptStart() {
+ browser.runtime.sendMessage([
+ "script-run",
+ ["loading"],
+ document.readyState,
+ ]);
+ }
+ function contentScriptEnd() {
+ browser.runtime.sendMessage([
+ "script-run",
+ ["interactive", "complete"],
+ document.readyState,
+ ]);
+ }
+ function contentScriptIdle() {
+ browser.runtime.sendMessage([
+ "script-run",
+ ["complete"],
+ document.readyState,
+ ]);
+ }
+
+ function contentScript() {
+ let manifest = browser.runtime.getManifest();
+ void manifest.permissions;
+ browser.runtime.sendMessage(["chrome-namespace-ok"]);
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: ["http://localhost/*/file_sample.html"],
+ },
+ background,
+
+ files: {
+ "content_script_start.js": contentScriptStart,
+ "content_script_end.js": contentScriptEnd,
+ "content_script_idle.js": contentScriptIdle,
+ "content_script.js": contentScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ let loadingCount = 0;
+ let interactiveCount = 0;
+ let completeCount = 0;
+ extension.onMessage("script-run-loading", () => {
+ loadingCount++;
+ });
+ extension.onMessage("script-run-interactive", () => {
+ interactiveCount++;
+ });
+
+ let completePromise = new Promise(resolve => {
+ extension.onMessage("script-run-complete", () => {
+ completeCount++;
+ if (completeCount == 2) {
+ resolve();
+ }
+ });
+ });
+
+ let chromeNamespacePromise = extension.awaitMessage("chrome-namespace-ok");
+
+ // Ensure that a content page running in a content process and which has been
+ // already loaded when the content scripts has been registered, it has received
+ // and registered the expected content scripts.
+ let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ await contentPage.loadURL(`${BASE_URL}/file_sample.html`);
+
+ await Promise.all([completePromise, chromeNamespacePromise]);
+
+ await contentPage.close();
+
+ // Expect two content scripts to run (one registered using an extension URL
+ // and one registered from plain JS code).
+ equal(loadingCount, 2, "document_start script ran exactly twice");
+ equal(interactiveCount, 2, "document_end script ran exactly twice");
+ equal(completeCount, 2, "document_idle script ran exactly twice");
+
+ await extension.unload();
+});
+
+// Test that the contentScripts.register options are correctly translated
+// into the expected WebExtensionContentScript properties.
+add_task(async function test_contentscripts_register_all_options() {
+ async function background() {
+ await browser.contentScripts.register({
+ js: [{ file: "content_script.js" }],
+ css: [{ file: "content_style.css" }],
+ matches: ["http://localhost/*"],
+ excludeMatches: ["http://localhost/exclude/*"],
+ excludeGlobs: ["*_exclude.html"],
+ includeGlobs: ["*_include.html"],
+ allFrames: true,
+ matchAboutBlank: true,
+ runAt: "document_start",
+ });
+
+ browser.test.sendMessage("background-ready", window.location.origin);
+ }
+
+ const extensionData = {
+ manifest: {
+ permissions: ["http://localhost/*"],
+ },
+ background,
+
+ files: {
+ "content_script.js": "",
+ "content_style.css": "",
+ },
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ const baseExtURL = await extension.awaitMessage("background-ready");
+
+ const policy = WebExtensionPolicy.getByID(extension.id);
+
+ ok(policy, "Got the WebExtensionPolicy for the test extension");
+ equal(
+ policy.contentScripts.length,
+ 1,
+ "Got the expected number of registered content scripts"
+ );
+
+ const script = policy.contentScripts[0];
+ let {
+ allFrames,
+ cssPaths,
+ jsPaths,
+ matchAboutBlank,
+ runAt,
+ originAttributesPatterns,
+ } = script;
+
+ deepEqual(
+ {
+ allFrames,
+ cssPaths,
+ jsPaths,
+ matchAboutBlank,
+ runAt,
+ originAttributesPatterns,
+ },
+ {
+ allFrames: true,
+ cssPaths: [`${baseExtURL}/content_style.css`],
+ jsPaths: [`${baseExtURL}/content_script.js`],
+ matchAboutBlank: true,
+ runAt: "document_start",
+ originAttributesPatterns: null,
+ },
+ "Got the expected content script properties"
+ );
+
+ ok(
+ script.matchesURI(Services.io.newURI("http://localhost/ok_include.html")),
+ "matched and include globs should match"
+ );
+ ok(
+ !script.matchesURI(
+ Services.io.newURI("http://localhost/exclude/ok_include.html")
+ ),
+ "exclude matches should not match"
+ );
+ ok(
+ !script.matchesURI(Services.io.newURI("http://localhost/ok_exclude.html")),
+ "exclude globs should not match"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_contentscripts_register_cookieStoreId() {
+ async function background() {
+ let cookieStoreIdCSSArray = [
+ { id: null, color: "rgb(123, 45, 67)" },
+ { id: "firefox-private", color: "rgb(255,255,0)" },
+ { id: "firefox-default", color: "red" },
+ { id: "firefox-container-1", color: "green" },
+ { id: "firefox-container-2", color: "blue" },
+ {
+ id: ["firefox-container-3", "firefox-container-4"],
+ color: "rgb(100,100,0)",
+ },
+ ];
+ const matches = ["http://localhost/*/file_sample_registered_styles.html"];
+
+ for (let { id, color } of cookieStoreIdCSSArray) {
+ await browser.contentScripts.register({
+ css: [
+ {
+ code: `#registered-extension-text-style {
+ background-color: ${color}}`,
+ },
+ ],
+ matches,
+ runAt: "document_start",
+ cookieStoreId: id,
+ });
+ }
+ await browser.test.assertRejects(
+ browser.contentScripts.register({
+ css: [{ code: `body {}` }],
+ matches,
+ cookieStoreId: "not_a_valid_cookieStoreId",
+ }),
+ /Invalid cookieStoreId/,
+ "contentScripts.register with an invalid cookieStoreId"
+ );
+
+ if (!navigator.userAgent.includes("Android")) {
+ await browser.test.assertRejects(
+ browser.contentScripts.register({
+ css: [{ code: `body {}` }],
+ matches,
+ cookieStoreId: "firefox-container-999",
+ }),
+ /Invalid cookieStoreId/,
+ "contentScripts.register with an invalid cookieStoreId"
+ );
+ } else {
+ // On Android, any firefox-container-... is treated as valid, so it doesn't
+ // result in an error.
+ // TODO bug 1743616: Fix implementation and remove this branch.
+ await browser.contentScripts.register({
+ css: [{ code: `body {}` }],
+ matches,
+ cookieStoreId: "firefox-container-999",
+ });
+ }
+
+ await browser.test.assertRejects(
+ browser.contentScripts.register({
+ css: [{ code: `body {}` }],
+ matches,
+ cookieStoreId: "",
+ }),
+ /Invalid cookieStoreId/,
+ "contentScripts.register with an invalid cookieStoreId"
+ );
+
+ browser.test.sendMessage("background_ready");
+ }
+
+ const extensionData = {
+ manifest: {
+ permissions: [
+ "http://localhost/*/file_sample_registered_styles.html",
+ "<all_urls>",
+ ],
+ content_scripts: [
+ {
+ matches: ["http://localhost/*/file_sample_registered_styles.html"],
+ run_at: "document_idle",
+ js: ["check_applied_styles.js"],
+ },
+ ],
+ },
+ background,
+ files: {
+ "check_applied_styles.js": check_applied_styles,
+ },
+ };
+
+ const extension = ExtensionTestUtils.loadExtension({
+ ...extensionData,
+ incognitoOverride: "spanning",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background_ready");
+ // Index 0 is the one from manifest.json.
+ let contentScriptMatchTests = [
+ {
+ contentPageOptions: { userContextId: 5 },
+ expectedStyles: "rgb(123, 45, 67)",
+ originAttributesPatternExpected: null,
+ contentScriptIndex: 1,
+ },
+ {
+ contentPageOptions: { privateBrowsing: true },
+ expectedStyles: "rgb(255, 255, 0)",
+ originAttributesPatternExpected: [
+ {
+ privateBrowsingId: 1,
+ userContextId: 0,
+ },
+ ],
+ contentScriptIndex: 2,
+ },
+ {
+ contentPageOptions: { userContextId: 0 },
+ expectedStyles: "rgb(255, 0, 0)",
+ originAttributesPatternExpected: [
+ {
+ privateBrowsingId: 0,
+ userContextId: 0,
+ },
+ ],
+ contentScriptIndex: 3,
+ },
+ {
+ contentPageOptions: { userContextId: 1 },
+ expectedStyles: "rgb(0, 128, 0)",
+ originAttributesPatternExpected: [{ userContextId: 1 }],
+ contentScriptIndex: 4,
+ },
+ {
+ contentPageOptions: { userContextId: 2 },
+ expectedStyles: "rgb(0, 0, 255)",
+ originAttributesPatternExpected: [{ userContextId: 2 }],
+ contentScriptIndex: 5,
+ },
+ {
+ contentPageOptions: { userContextId: 3 },
+ expectedStyles: "rgb(100, 100, 0)",
+ originAttributesPatternExpected: [
+ { userContextId: 3 },
+ { userContextId: 4 },
+ ],
+ contentScriptIndex: 6,
+ },
+ {
+ contentPageOptions: { userContextId: 4 },
+ expectedStyles: "rgb(100, 100, 0)",
+ originAttributesPatternExpected: [
+ { userContextId: 3 },
+ { userContextId: 4 },
+ ],
+ contentScriptIndex: 6,
+ },
+ ];
+
+ const policy = WebExtensionPolicy.getByID(extension.id);
+
+ for (const testCase of contentScriptMatchTests) {
+ const {
+ contentPageOptions,
+ expectedStyles,
+ originAttributesPatternExpected,
+ contentScriptIndex,
+ } = testCase;
+ const script = policy.contentScripts[contentScriptIndex];
+
+ deepEqual(script.originAttributesPatterns, originAttributesPatternExpected);
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `about:blank`,
+ contentPageOptions
+ );
+ await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+ let registeredStylesResults = await extension.awaitMessage(
+ "registered-styles-results"
+ );
+
+ equal(
+ registeredStylesResults.registeredExtensionBlobStyleBG,
+ expectedStyles,
+ `Expected styles applied on content page loaded with options
+ ${JSON.stringify(contentPageOptions)}`
+ );
+ await contentPage.close();
+ }
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js
new file mode 100644
index 0000000000..335a278329
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js
@@ -0,0 +1,362 @@
+"use strict";
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+server.registerPathHandler("/worker.js", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/javascript", false);
+ response.write("let x = true;");
+});
+
+const baseCSP = [];
+// Keep in sync with extensions.webextensions.base-content-security-policy
+baseCSP[2] = {
+ "script-src": [
+ "'unsafe-eval'",
+ "'wasm-unsafe-eval'",
+ "'unsafe-inline'",
+ "blob:",
+ "filesystem:",
+ "http://localhost:*",
+ "http://127.0.0.1:*",
+ "https://*",
+ "moz-extension:",
+ "'self'",
+ ],
+};
+// Keep in sync with extensions.webextensions.base-content-security-policy.v3
+baseCSP[3] = {
+ "script-src": ["'self'", "'wasm-unsafe-eval'"],
+};
+
+/**
+ * @typedef TestPolicyExpects
+ * @type {object}
+ * @param {boolean} workerEvalAllowed
+ * @param {boolean} workerImportScriptsAllowed
+ * @param {boolean} workerWasmAllowed
+ */
+
+/**
+ * Tests that content security policies for an add-on are actually applied to *
+ * documents that belong to it. This tests both the base policies and add-on
+ * specific policies, and ensures that the parsed policies applied to the
+ * document's principal match what was specified in the policy string.
+ *
+ * @param {object} options
+ * @param {number} [options.manifest_version]
+ * @param {object} [options.customCSP]
+ * @param {TestPolicyExpects} options.expects
+ */
+async function testPolicy({
+ manifest_version = 2,
+ customCSP = null,
+ expects = {},
+}) {
+ info(
+ `Enter tests for extension CSP with ${JSON.stringify({
+ manifest_version,
+ customCSP,
+ })}`
+ );
+
+ let baseURL;
+
+ let addonCSP = {
+ "script-src": ["'self'"],
+ };
+
+ if (manifest_version < 3) {
+ addonCSP["script-src"].push("'wasm-unsafe-eval'");
+ }
+
+ let content_security_policy = null;
+
+ if (customCSP) {
+ for (let key of Object.keys(customCSP)) {
+ addonCSP[key] = customCSP[key].split(/\s+/);
+ }
+
+ content_security_policy = Object.keys(customCSP)
+ .map(key => `${key} ${customCSP[key]}`)
+ .join("; ");
+ }
+
+ function checkSource(name, policy, expected) {
+ // fallback to script-src when comparing worker-src if policy does not include worker-src
+ let policySrc =
+ name != "worker-src" || policy[name]
+ ? policy[name]
+ : policy["script-src"];
+ equal(
+ JSON.stringify(policySrc.sort()),
+ JSON.stringify(expected[name].sort()),
+ `Expected value for ${name}`
+ );
+ }
+
+ function checkCSP(csp, location) {
+ let policies = csp["csp-policies"];
+
+ info(`Base policy for ${location}`);
+ let base = baseCSP[manifest_version];
+
+ equal(policies[0]["report-only"], false, "Policy is not report-only");
+ for (let key in base) {
+ checkSource(key, policies[0], base);
+ }
+
+ info(`Add-on policy for ${location}`);
+
+ equal(policies[1]["report-only"], false, "Policy is not report-only");
+ for (let key in addonCSP) {
+ checkSource(key, policies[1], addonCSP);
+ }
+ }
+
+ function background() {
+ browser.test.sendMessage(
+ "base-url",
+ browser.runtime.getURL("").replace(/\/$/, "")
+ );
+
+ browser.test.sendMessage("background-csp", window.getCsp());
+ }
+
+ function tabScript() {
+ browser.test.sendMessage("tab-csp", window.getCsp());
+
+ const worker = new Worker("worker.js");
+ worker.onmessage = event => {
+ browser.test.sendMessage("worker-csp", event.data);
+ };
+
+ worker.postMessage({});
+ }
+
+ function testWorker(port) {
+ this.onmessage = () => {
+ let importScriptsAllowed;
+ let evalAllowed;
+ let wasmAllowed;
+
+ try {
+ eval("let y = true;"); // eslint-disable-line no-eval
+ evalAllowed = true;
+ } catch (e) {
+ evalAllowed = false;
+ }
+
+ try {
+ new WebAssembly.Module(
+ new Uint8Array([0, 0x61, 0x73, 0x6d, 0x1, 0, 0, 0])
+ );
+ wasmAllowed = true;
+ } catch (e) {
+ wasmAllowed = false;
+ }
+
+ try {
+ // eslint-disable-next-line no-undef
+ importScripts(`http://127.0.0.1:${port}/worker.js`);
+ importScriptsAllowed = true;
+ } catch (e) {
+ importScriptsAllowed = false;
+ }
+
+ postMessage({ evalAllowed, importScriptsAllowed, wasmAllowed });
+ };
+ }
+
+ let web_accessible_resources = ["content.html", "tab.html"];
+ if (manifest_version == 3) {
+ let extension_pages = content_security_policy;
+ content_security_policy = {
+ extension_pages,
+ };
+ let resources = web_accessible_resources;
+ web_accessible_resources = [
+ { resources, matches: ["http://example.com/*"] },
+ ];
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+
+ files: {
+ "tab.html": `<html><head><meta charset="utf-8">
+ <script src="tab.js"></${"script"}></head></html>`,
+
+ "tab.js": tabScript,
+
+ "content.html": `<html><head><meta charset="utf-8"></head></html>`,
+ "worker.js": `(${testWorker})(${server.identity.primaryPort})`,
+ },
+
+ manifest: {
+ manifest_version,
+ content_security_policy,
+ web_accessible_resources,
+ },
+ });
+
+ function frameScript() {
+ // eslint-disable-next-line mozilla/balanced-listeners
+ addEventListener(
+ "DOMWindowCreated",
+ event => {
+ let win = event.target.ownerGlobal;
+ function getCsp() {
+ let { cspJSON } = win.document;
+ return win.wrappedJSObject.JSON.parse(cspJSON);
+ }
+ Cu.exportFunction(getCsp, win, { defineAs: "getCsp" });
+ },
+ true
+ );
+ }
+ let frameScriptURL = `data:,(${encodeURI(frameScript)}).call(this)`;
+ Services.mm.loadFrameScript(frameScriptURL, true, true);
+
+ info(`Testing CSP for policy: ${JSON.stringify(content_security_policy)}`);
+
+ await extension.startup();
+
+ baseURL = await extension.awaitMessage("base-url");
+
+ let tabPage = await ExtensionTestUtils.loadContentPage(
+ `${baseURL}/tab.html`,
+ { extension }
+ );
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+
+ let contentCSP = await contentPage.spawn(
+ [`${baseURL}/content.html`],
+ async src => {
+ let doc = this.content.document;
+
+ let frame = doc.createElement("iframe");
+ frame.src = src;
+ doc.body.appendChild(frame);
+
+ await new Promise(resolve => {
+ frame.onload = resolve;
+ });
+
+ return frame.contentWindow.wrappedJSObject.getCsp();
+ }
+ );
+
+ let backgroundCSP = await extension.awaitMessage("background-csp");
+ checkCSP(backgroundCSP, "background page");
+
+ let tabCSP = await extension.awaitMessage("tab-csp");
+ checkCSP(tabCSP, "tab page");
+
+ checkCSP(contentCSP, "content frame");
+
+ let workerCSP = await extension.awaitMessage("worker-csp");
+ equal(
+ workerCSP.importScriptsAllowed,
+ expects.workerImportAllowed,
+ "worker importScript"
+ );
+ equal(workerCSP.evalAllowed, expects.workerEvalAllowed, "worker eval");
+ equal(workerCSP.wasmAllowed, expects.workerWasmAllowed, "worker wasm");
+
+ await contentPage.close();
+ await tabPage.close();
+
+ await extension.unload();
+
+ Services.mm.removeDelayedFrameScript(frameScriptURL);
+}
+
+add_task(async function testCSP() {
+ await testPolicy({
+ manifest_version: 2,
+ customCSP: null,
+ expects: {
+ workerEvalAllowed: false,
+ workerImportAllowed: false,
+ workerWasmAllowed: true,
+ },
+ });
+
+ let hash =
+ "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='";
+
+ await testPolicy({
+ manifest_version: 2,
+ customCSP: {
+ "script-src": `'self' https://*.example.com 'unsafe-eval' ${hash}`,
+ },
+ expects: {
+ workerEvalAllowed: true,
+ workerImportAllowed: false,
+ workerWasmAllowed: true,
+ },
+ });
+
+ await testPolicy({
+ manifest_version: 2,
+ customCSP: {
+ "script-src": `'self'`,
+ },
+ expects: {
+ workerEvalAllowed: false,
+ workerImportAllowed: false,
+ workerWasmAllowed: true,
+ },
+ });
+
+ await testPolicy({
+ manifest_version: 3,
+ customCSP: {
+ "script-src": `'self' ${hash}`,
+ "worker-src": `'self'`,
+ },
+ expects: {
+ workerEvalAllowed: false,
+ workerImportAllowed: false,
+ workerWasmAllowed: false,
+ },
+ });
+
+ await testPolicy({
+ manifest_version: 3,
+ customCSP: {
+ "script-src": `'self'`,
+ "worker-src": `'self'`,
+ },
+ expects: {
+ workerEvalAllowed: false,
+ workerImportAllowed: false,
+ workerWasmAllowed: false,
+ },
+ });
+
+ await testPolicy({
+ manifest_version: 3,
+ customCSP: {
+ "script-src": `'self' 'wasm-unsafe-eval'`,
+ "worker-src": `'self' 'wasm-unsafe-eval'`,
+ },
+ expects: {
+ workerEvalAllowed: false,
+ workerImportAllowed: false,
+ workerWasmAllowed: true,
+ },
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js
new file mode 100644
index 0000000000..d35f572731
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js
@@ -0,0 +1,270 @@
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+add_task(async function test_contentscript_runAt() {
+ function background() {
+ browser.runtime.onMessage.addListener(
+ ([msg, expectedStates, readyState], sender) => {
+ if (msg == "chrome-namespace-ok") {
+ browser.test.sendMessage(msg);
+ return;
+ }
+
+ browser.test.assertEq("script-run", msg, "message type is correct");
+ browser.test.assertTrue(
+ expectedStates.includes(readyState),
+ `readyState "${readyState}" is one of [${expectedStates}]`
+ );
+ browser.test.sendMessage("script-run-" + expectedStates[0]);
+ }
+ );
+ }
+
+ function contentScriptStart() {
+ browser.runtime.sendMessage([
+ "script-run",
+ ["loading"],
+ document.readyState,
+ ]);
+ }
+ function contentScriptEnd() {
+ browser.runtime.sendMessage([
+ "script-run",
+ ["interactive", "complete"],
+ document.readyState,
+ ]);
+ }
+ function contentScriptIdle() {
+ browser.runtime.sendMessage([
+ "script-run",
+ ["complete"],
+ document.readyState,
+ ]);
+ }
+
+ function contentScript() {
+ let manifest = browser.runtime.getManifest();
+ void manifest.applications.gecko.id;
+ browser.runtime.sendMessage(["chrome-namespace-ok"]);
+ }
+
+ let extensionData = {
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "contentscript@tests.mozilla.org" },
+ },
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script_start.js"],
+ run_at: "document_start",
+ },
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script_end.js"],
+ run_at: "document_end",
+ },
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script_idle.js"],
+ run_at: "document_idle",
+ },
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script_idle.js"],
+ // Test default `run_at`.
+ },
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+ },
+ background,
+
+ files: {
+ "content_script_start.js": contentScriptStart,
+ "content_script_end.js": contentScriptEnd,
+ "content_script_idle.js": contentScriptIdle,
+ "content_script.js": contentScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ let loadingCount = 0;
+ let interactiveCount = 0;
+ let completeCount = 0;
+ extension.onMessage("script-run-loading", () => {
+ loadingCount++;
+ });
+ extension.onMessage("script-run-interactive", () => {
+ interactiveCount++;
+ });
+
+ let completePromise = new Promise(resolve => {
+ extension.onMessage("script-run-complete", () => {
+ completeCount++;
+ if (completeCount > 1) {
+ resolve();
+ }
+ });
+ });
+
+ let chromeNamespacePromise = extension.awaitMessage("chrome-namespace-ok");
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await Promise.all([completePromise, chromeNamespacePromise]);
+
+ await contentPage.close();
+
+ equal(loadingCount, 1, "document_start script ran exactly once");
+ equal(interactiveCount, 1, "document_end script ran exactly once");
+ equal(completeCount, 2, "document_idle script ran exactly twice");
+
+ await extension.unload();
+});
+
+add_task(async function test_contentscript_window_open() {
+ if (AppConstants.DEBUG && ExtensionTestUtils.remoteContentScripts) {
+ return;
+ }
+
+ let script = async () => {
+ /* globals x */
+ browser.test.assertEq(1, x, "Should only run once");
+
+ if (top !== window) {
+ // Wait for our parent page to load, then set a timeout to wait for the
+ // document.open call, so we make sure to not tear down the extension
+ // until after we've done the document.open.
+ await new Promise(resolve => {
+ top.addEventListener("load", () => setTimeout(resolve, 0), {
+ once: true,
+ });
+ });
+ }
+
+ browser.test.sendMessage("content-script", [location.href, top === window]);
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "contentscript@tests.mozilla.org" },
+ },
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ match_about_blank: true,
+ all_frames: true,
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": `
+ var x = (x || 0) + 1;
+ (${script})();
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ let url = `${BASE_URL}/file_document_open.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+
+ let [pageURL, pageIsTop] = await extension.awaitMessage("content-script");
+
+ // Sometimes we get a content script load for the initial about:blank
+ // top level frame here, sometimes we don't. Either way is fine, as long as we
+ // don't get two loads into the same document.open() document.
+ if (pageURL === "about:blank") {
+ equal(pageIsTop, true);
+ [pageURL, pageIsTop] = await extension.awaitMessage("content-script");
+ }
+
+ Assert.deepEqual([pageURL, pageIsTop], [url, true]);
+
+ let [frameURL, isTop] = await extension.awaitMessage("content-script");
+ Assert.deepEqual([frameURL, isTop], [url, false]);
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test verify that a cached script is still able to catch the document
+// while it is still loading (when we do not block the document parsing as
+// we do for a non cached script).
+add_task(async function test_cached_contentscript_on_document_start() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://localhost/*/file_document_open.html"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": `
+ browser.test.sendMessage("content-script-loaded", {
+ url: window.location.href,
+ documentReadyState: document.readyState,
+ });
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ let url = `${BASE_URL}/file_document_open.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+
+ let msg = await extension.awaitMessage("content-script-loaded");
+ Assert.deepEqual(
+ msg,
+ {
+ url,
+ documentReadyState: "loading",
+ },
+ "Got the expected url and document.readyState from a non cached script"
+ );
+
+ // Reload the page and check that the cached content script is still able to
+ // run on document_start.
+ await contentPage.loadURL(url);
+
+ let msgFromCached = await extension.awaitMessage("content-script-loaded");
+ Assert.deepEqual(
+ msgFromCached,
+ {
+ url,
+ documentReadyState: "loading",
+ },
+ "Got the expected url and document.readyState from a cached script"
+ );
+
+ await extension.unload();
+
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js
new file mode 100644
index 0000000000..1297f105a1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js
@@ -0,0 +1,78 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/blank-iframe.html", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write("<iframe></iframe>");
+});
+
+add_task(async function content_script_at_document_start() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ js: ["start.js"],
+ run_at: "document_start",
+ match_about_blank: true,
+ },
+ ],
+ },
+
+ files: {
+ "start.js": function () {
+ browser.test.sendMessage("content-script-done");
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+ await extension.awaitMessage("content-script-done");
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function content_style_at_document_start() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ css: ["start.css"],
+ run_at: "document_start",
+ match_about_blank: true,
+ },
+ {
+ matches: ["<all_urls>"],
+ js: ["end.js"],
+ run_at: "document_end",
+ match_about_blank: true,
+ },
+ ],
+ },
+
+ files: {
+ "start.css": "body { background: red; }",
+ "end.js": function () {
+ let style = window.getComputedStyle(document.body);
+ browser.test.assertEq(
+ "rgb(255, 0, 0)",
+ style.backgroundColor,
+ "document_start style should have been applied"
+ );
+ browser.test.sendMessage("content-script-done");
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+ await extension.awaitMessage("content-script-done");
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js
new file mode 100644
index 0000000000..4e42181e71
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js
@@ -0,0 +1,65 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_contentscript_api_injection() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ web_accessible_resources: ["content_script_iframe.html"],
+ },
+
+ files: {
+ "content_script.js"() {
+ let iframe = document.createElement("iframe");
+ iframe.src = browser.runtime.getURL("content_script_iframe.html");
+ document.body.appendChild(iframe);
+ },
+ "content_script_iframe.js"() {
+ window.location = `http://example.com/data/file_privilege_escalation.html`;
+ },
+ "content_script_iframe.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script type="text/javascript" src="content_script_iframe.js"></script>
+ </head>
+ </html>`,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ let awaitConsole = new Promise(resolve => {
+ Services.console.registerListener(function listener(message) {
+ if (/WebExt Privilege Escalation/.test(message.message)) {
+ Services.console.unregisterListener(listener);
+ resolve(message);
+ }
+ });
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ let message = await awaitConsole;
+ ok(
+ message.message.includes(
+ "WebExt Privilege Escalation: typeof(browser) = undefined"
+ ),
+ "Document does not have `browser` APIs."
+ );
+
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js
new file mode 100644
index 0000000000..cb9a07142d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js
@@ -0,0 +1,79 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_async_loading() {
+ const adder = `(function add(a = 1) { this.count += a; })();\n`;
+
+ const extension = {
+ manifest: {
+ content_scripts: [
+ {
+ run_at: "document_start",
+ matches: ["http://example.com/dummy"],
+ js: ["first.js", "second.js"],
+ },
+ {
+ run_at: "document_end",
+ matches: ["http://example.com/dummy"],
+ js: ["third.js"],
+ },
+ ],
+ },
+ files: {
+ "first.js": `
+ this.count = 0;
+ ${adder.repeat(50000)}; // 2Mb
+ browser.test.assertEq(this.count, 50000, "A 50k line script");
+
+ this.order = (this.order || 0) + 1;
+ browser.test.sendMessage("first", this.order);
+ `,
+ "second.js": `
+ this.order = (this.order || 0) + 1;
+ browser.test.sendMessage("second", this.order);
+ `,
+ "third.js": `
+ this.order = (this.order || 0) + 1;
+ browser.test.sendMessage("third", this.order);
+ `,
+ },
+ };
+
+ async function checkOrder(ext) {
+ const [first, second, third] = await Promise.all([
+ ext.awaitMessage("first"),
+ ext.awaitMessage("second"),
+ ext.awaitMessage("third"),
+ ]);
+
+ equal(first, 1, "first.js finished execution first.");
+ equal(second, 2, "second.js finished execution second.");
+ equal(third, 3, "third.js finished execution third.");
+ }
+
+ info("Test pages observed while extension is running");
+ const observed = ExtensionTestUtils.loadExtension(extension);
+ await observed.startup();
+
+ const contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+ await checkOrder(observed);
+ await observed.unload();
+
+ info("Test pages already existing on extension startup");
+ const existing = ExtensionTestUtils.loadExtension(extension);
+
+ await existing.startup();
+ await checkOrder(existing);
+ await existing.unload();
+
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js
new file mode 100644
index 0000000000..4ac22dc700
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js
@@ -0,0 +1,128 @@
+"use strict";
+
+const server = createHttpServer({
+ hosts: ["green.example.com", "red.example.com"],
+});
+
+server.registerDirectory("/data/", do_get_file("data"));
+
+server.registerPathHandler("/pixel.html", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write(`<!DOCTYPE html>
+ <script>
+ function readByWeb() {
+ let ctx = document.querySelector("canvas").getContext("2d");
+ let {data} = ctx.getImageData(0, 0, 1, 1);
+ return data.slice(0, 3).join();
+ }
+ </script>
+ `);
+});
+
+add_task(async function test_contentscript_canvas_tainting() {
+ async function contentScript() {
+ let canvas = document.createElement("canvas");
+ let ctx = canvas.getContext("2d");
+ document.body.appendChild(canvas);
+
+ function draw(url) {
+ return new Promise(resolve => {
+ let img = document.createElement("img");
+ img.onload = () => {
+ ctx.drawImage(img, 0, 0, 1, 1);
+ resolve();
+ };
+ img.src = url;
+ });
+ }
+
+ function readByExt() {
+ let { data } = ctx.getImageData(0, 0, 1, 1);
+ return data.slice(0, 3).join();
+ }
+
+ let readByWeb = window.wrappedJSObject.readByWeb;
+
+ // Test reading after drawing an image from the same origin as the web page.
+ await draw("http://green.example.com/data/pixel_green.gif");
+ browser.test.assertEq(
+ readByWeb(),
+ "0,255,0",
+ "Content can read same-origin image"
+ );
+ browser.test.assertEq(
+ readByExt(),
+ "0,255,0",
+ "Extension can read same-origin image"
+ );
+
+ // Test reading after drawing a blue pixel data URI from extension content script.
+ await draw(
+ "data:image/gif;base64,R0lGODlhAQABAIABAAAA/wAAACwAAAAAAQABAAACAkQBADs="
+ );
+ browser.test.assertThrows(
+ readByWeb,
+ /operation is insecure/,
+ "Content can't read extension's image"
+ );
+ browser.test.assertEq(
+ readByExt(),
+ "0,0,255",
+ "Extension can read its own image"
+ );
+
+ // Test after tainting the canvas with an image from a third party domain.
+ await draw("http://red.example.com/data/pixel_red.gif");
+ browser.test.assertThrows(
+ readByWeb,
+ /operation is insecure/,
+ "Content can't read third party image"
+ );
+ browser.test.assertThrows(
+ readByExt,
+ /operation is insecure/,
+ "Extension can't read fully tainted"
+ );
+
+ // Test canvas is still fully tainted after drawing extension's data: image again.
+ await draw(
+ "data:image/gif;base64,R0lGODlhAQABAIABAAAA/wAAACwAAAAAAQABAAACAkQBADs="
+ );
+ browser.test.assertThrows(
+ readByWeb,
+ /operation is insecure/,
+ "Canvas still fully tainted for content"
+ );
+ browser.test.assertThrows(
+ readByExt,
+ /operation is insecure/,
+ "Canvas still fully tainted for extension"
+ );
+
+ browser.test.sendMessage("done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://green.example.com/pixel.html"],
+ js: ["cs.js"],
+ },
+ ],
+ },
+ files: {
+ "cs.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://green.example.com/pixel.html"
+ );
+ await extension.awaitMessage("done");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js
new file mode 100644
index 0000000000..fc27b84200
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js
@@ -0,0 +1,359 @@
+"use strict";
+
+/* eslint-disable mozilla/balanced-listeners */
+
+const server = createHttpServer({ hosts: ["example.com", "example.org"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+function loadExtension() {
+ function contentScript() {
+ browser.test.sendMessage("content-script-ready");
+
+ window.addEventListener(
+ "pagehide",
+ () => {
+ browser.test.sendMessage("content-script-hide");
+ },
+ true
+ );
+ window.addEventListener("pageshow", () => {
+ browser.test.sendMessage("content-script-show");
+ });
+ }
+
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy*"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+}
+
+add_task(async function test_contentscript_context() {
+ let extension = loadExtension();
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+ await extension.awaitMessage("content-script-ready");
+ await extension.awaitMessage("content-script-show");
+
+ // Get the content script context and check that it points to the correct window.
+ await contentPage.legacySpawn(extension.id, async extensionId => {
+ const { ExtensionContent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionContent.sys.mjs"
+ );
+ this.context = ExtensionContent.getContextByExtensionId(
+ extensionId,
+ this.content
+ );
+
+ Assert.ok(this.context, "Got content script context");
+
+ Assert.equal(
+ this.context.contentWindow,
+ this.content,
+ "Context's contentWindow property is correct"
+ );
+
+ // Navigate so that the content page is hidden in the bfcache.
+
+ this.content.location = "http://example.org/dummy";
+ });
+
+ await extension.awaitMessage("content-script-hide");
+
+ await contentPage.legacySpawn(null, async () => {
+ Assert.equal(
+ this.context.contentWindow,
+ null,
+ "Context's contentWindow property is null"
+ );
+
+ // Navigate back so the content page is resurrected from the bfcache.
+ this.content.history.back();
+ });
+
+ await extension.awaitMessage("content-script-show");
+
+ await contentPage.legacySpawn(null, async () => {
+ Assert.equal(
+ this.context.contentWindow,
+ this.content,
+ "Context's contentWindow property is correct"
+ );
+ });
+
+ await contentPage.close();
+ await extension.awaitMessage("content-script-hide");
+ await extension.unload();
+});
+
+add_task(async function test_contentscript_context_incognito_not_allowed() {
+ async function background() {
+ await browser.contentScripts.register({
+ js: [{ file: "registered_script.js" }],
+ matches: ["http://example.com/dummy"],
+ runAt: "document_start",
+ });
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ },
+ ],
+ permissions: ["http://example.com/*"],
+ },
+ background,
+ files: {
+ "content_script.js": () => {
+ browser.test.notifyFail("content_script_loaded");
+ },
+ "registered_script.js": () => {
+ browser.test.notifyFail("registered_script_loaded");
+ },
+ },
+ });
+
+ // Bug 1715801: Re-enable pbm portion on GeckoView
+ if (AppConstants.platform == "android") {
+ Services.prefs.setBoolPref("dom.security.https_first_pbm", false);
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy",
+ { privateBrowsing: true }
+ );
+
+ await contentPage.legacySpawn(extension.id, async extensionId => {
+ const { ExtensionContent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionContent.sys.mjs"
+ );
+ let context = ExtensionContent.getContextByExtensionId(
+ extensionId,
+ this.content
+ );
+ Assert.equal(
+ context,
+ null,
+ "Extension unable to use content_script in private browsing window"
+ );
+ });
+
+ await contentPage.close();
+ await extension.unload();
+
+ // Bug 1715801: Re-enable pbm portion on GeckoView
+ if (AppConstants.platform == "android") {
+ Services.prefs.clearUserPref("dom.security.https_first_pbm");
+ }
+});
+
+add_task(async function test_contentscript_context_unload_while_in_bfcache() {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy?first"
+ );
+ let extension = loadExtension();
+ await extension.startup();
+ await extension.awaitMessage("content-script-ready");
+
+ // Get the content script context and check that it points to the correct window.
+ await contentPage.legacySpawn(extension.id, async extensionId => {
+ const { ExtensionContent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionContent.sys.mjs"
+ );
+ // Save context so we can verify that contentWindow is nulled after unload.
+ this.context = ExtensionContent.getContextByExtensionId(
+ extensionId,
+ this.content
+ );
+
+ Assert.equal(
+ this.context.contentWindow,
+ this.content,
+ "Context's contentWindow property is correct"
+ );
+
+ this.contextUnloadedPromise = new Promise(resolve => {
+ this.context.callOnClose({ close: resolve });
+ });
+ this.pageshownPromise = new Promise(resolve => {
+ this.content.addEventListener(
+ "pageshow",
+ () => {
+ // Yield to the event loop once more to ensure that all pageshow event
+ // handlers have been dispatched before fulfilling the promise.
+ let { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+ );
+ setTimeout(resolve, 0);
+ },
+ { once: true, mozSystemGroup: true }
+ );
+ });
+
+ // Navigate so that the content page is hidden in the bfcache.
+ this.content.location = "http://example.org/dummy?second";
+ });
+
+ await extension.awaitMessage("content-script-hide");
+
+ await extension.unload();
+ await contentPage.legacySpawn(null, async () => {
+ await this.contextUnloadedPromise;
+ Assert.equal(this.context.unloaded, true, "Context has been unloaded");
+
+ // Normally, when a page is not in the bfcache, context.contentWindow is
+ // not null when the callOnClose handler is invoked (this is checked by the
+ // previous subtest).
+ // Now wait a little bit and check again to ensure that the contentWindow
+ // property is not somehow restored.
+ await new Promise(resolve => this.content.setTimeout(resolve, 0));
+ Assert.equal(
+ this.context.contentWindow,
+ null,
+ "Context's contentWindow property is null"
+ );
+
+ // Navigate back so the content page is resurrected from the bfcache.
+ this.content.history.back();
+
+ await this.pageshownPromise;
+
+ Assert.equal(
+ this.context.contentWindow,
+ null,
+ "Context's contentWindow property is null after restore from bfcache"
+ );
+ });
+
+ await contentPage.close();
+});
+
+add_task(async function test_contentscript_context_valid_during_execution() {
+ // This test does the following:
+ // - Load page
+ // - Load extension; inject content script.
+ // - Navigate page; pagehide triggered.
+ // - Navigate back; pageshow triggered.
+ // - Close page; pagehide, unload triggered.
+ // At each of these last four events, the validity of the context is checked.
+
+ function contentScript() {
+ browser.test.sendMessage("content-script-ready");
+ window.wrappedJSObject.checkContextIsValid("Context is valid on execution");
+
+ window.addEventListener(
+ "pagehide",
+ () => {
+ window.wrappedJSObject.checkContextIsValid(
+ "Context is valid on pagehide"
+ );
+ browser.test.sendMessage("content-script-hide");
+ },
+ true
+ );
+ window.addEventListener("pageshow", () => {
+ window.wrappedJSObject.checkContextIsValid(
+ "Context is valid on pageshow"
+ );
+
+ // This unload listener is registered after pageshow, to ensure that the
+ // page can be stored in the bfcache at the previous pagehide.
+ window.addEventListener("unload", () => {
+ window.wrappedJSObject.checkContextIsValid(
+ "Context is valid on unload"
+ );
+ browser.test.sendMessage("content-script-unload");
+ });
+
+ browser.test.sendMessage("content-script-show");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy*"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy?first"
+ );
+ await contentPage.legacySpawn(extension.id, async extensionId => {
+ let context;
+ let checkContextIsValid = description => {
+ if (!context) {
+ const { ExtensionContent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionContent.sys.mjs"
+ );
+ context = ExtensionContent.getContextByExtensionId(
+ extensionId,
+ this.content
+ );
+ }
+ Assert.equal(
+ context.contentWindow,
+ this.content,
+ `${description}: contentWindow`
+ );
+ Assert.equal(context.active, true, `${description}: active`);
+ };
+ Cu.exportFunction(checkContextIsValid, this.content, {
+ defineAs: "checkContextIsValid",
+ });
+ });
+ await extension.startup();
+ await extension.awaitMessage("content-script-ready");
+
+ await contentPage.legacySpawn(extension.id, async extensionId => {
+ // Navigate so that the content page is frozen in the bfcache.
+ this.content.location = "http://example.org/dummy?second";
+ });
+
+ await extension.awaitMessage("content-script-hide");
+ await contentPage.legacySpawn(null, async () => {
+ // Navigate back so the content page is resurrected from the bfcache.
+ this.content.history.back();
+ });
+
+ await extension.awaitMessage("content-script-show");
+ await contentPage.close();
+ await extension.awaitMessage("content-script-hide");
+ await extension.awaitMessage("content-script-unload");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js
new file mode 100644
index 0000000000..7794a66d57
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js
@@ -0,0 +1,168 @@
+"use strict";
+
+/* globals exportFunction */
+/* eslint-disable mozilla/balanced-listeners */
+
+const server = createHttpServer({ hosts: ["example.com", "example.org"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+server.registerPathHandler("/bfcachetestpage", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html;charset=utf-8", false);
+ response.write(`<!DOCTYPE html>
+<script>
+ window.addEventListener("pageshow", (event) => {
+ event.stopImmediatePropagation();
+ if (window.browserTestSendMessage) {
+ browserTestSendMessage("content-script-show");
+ }
+ });
+ window.addEventListener("pagehide", (event) => {
+ event.stopImmediatePropagation();
+ if (window.browserTestSendMessage) {
+ if (event.persisted) {
+ browserTestSendMessage("content-script-hide");
+ } else {
+ browserTestSendMessage("content-script-unload");
+ }
+ }
+ }, true);
+</script>`);
+});
+
+add_task(async function test_contentscript_context_isolation() {
+ function contentScript() {
+ browser.test.sendMessage("content-script-ready");
+
+ exportFunction(browser.test.sendMessage, window, {
+ defineAs: "browserTestSendMessage",
+ });
+
+ window.addEventListener("pageshow", () => {
+ browser.test.fail(
+ "pageshow should have been suppressed by stopImmediatePropagation"
+ );
+ });
+ window.addEventListener(
+ "pagehide",
+ () => {
+ browser.test.fail(
+ "pagehide should have been suppressed by stopImmediatePropagation"
+ );
+ },
+ true
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/bfcachetestpage"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/bfcachetestpage"
+ );
+ await extension.startup();
+ await extension.awaitMessage("content-script-ready");
+
+ // Get the content script context and check that it points to the correct window.
+ await contentPage.legacySpawn(extension.id, async extensionId => {
+ const { ExtensionContent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionContent.sys.mjs"
+ );
+ this.context = ExtensionContent.getContextByExtensionId(
+ extensionId,
+ this.content
+ );
+
+ Assert.ok(this.context, "Got content script context");
+
+ Assert.equal(
+ this.context.contentWindow,
+ this.content,
+ "Context's contentWindow property is correct"
+ );
+
+ // Navigate so that the content page is hidden in the bfcache.
+
+ this.content.location = "http://example.org/dummy?noscripthere1";
+ });
+
+ await extension.awaitMessage("content-script-hide");
+
+ await contentPage.legacySpawn(null, async () => {
+ Assert.equal(
+ this.context.contentWindow,
+ null,
+ "Context's contentWindow property is null"
+ );
+ Assert.ok(this.context.sandbox, "Context's sandbox exists");
+
+ // Navigate back so the content page is resurrected from the bfcache.
+ this.content.history.back();
+ });
+
+ await extension.awaitMessage("content-script-show");
+
+ async function testWithoutBfcache() {
+ return contentPage.legacySpawn(null, async () => {
+ Assert.equal(
+ this.context.contentWindow,
+ this.content,
+ "Context's contentWindow property is correct"
+ );
+ Assert.ok(this.context.sandbox, "Context's sandbox exists before unload");
+
+ let contextUnloadedPromise = new Promise(resolve => {
+ this.context.callOnClose({ close: resolve });
+ });
+
+ // Now add an "unload" event listener, which should prevent a page from entering the bfcache.
+ await new Promise(resolve => {
+ this.content.addEventListener("unload", () => {
+ Assert.equal(
+ this.context.contentWindow,
+ this.content,
+ "Context's contentWindow property should be non-null at unload"
+ );
+ resolve();
+ });
+ this.content.location = "http://example.org/dummy?noscripthere2";
+ });
+
+ await contextUnloadedPromise;
+ });
+ }
+ await runWithPrefs(
+ [["docshell.shistory.bfcache.allow_unload_listeners", false]],
+ testWithoutBfcache
+ );
+
+ await extension.awaitMessage("content-script-unload");
+
+ await contentPage.legacySpawn(null, async () => {
+ Assert.equal(
+ this.context.sandbox,
+ null,
+ "Context's sandbox has been destroyed after unload"
+ );
+ });
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js
new file mode 100644
index 0000000000..41d9901c80
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js
@@ -0,0 +1,177 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_contentscript_create_iframe() {
+ function background() {
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ let { name, availableAPIs, manifest, testGetManifest } = msg;
+ let hasExtTabsAPI = availableAPIs.indexOf("tabs") > 0;
+ let hasExtWindowsAPI = availableAPIs.indexOf("windows") > 0;
+
+ browser.test.assertFalse(
+ hasExtTabsAPI,
+ "the created iframe should not be able to use privileged APIs (tabs)"
+ );
+ browser.test.assertFalse(
+ hasExtWindowsAPI,
+ "the created iframe should not be able to use privileged APIs (windows)"
+ );
+
+ let {
+ browser_specific_settings: {
+ gecko: { id: expectedManifestGeckoId },
+ },
+ } = chrome.runtime.getManifest();
+ let {
+ browser_specific_settings: {
+ gecko: { id: actualManifestGeckoId },
+ },
+ } = manifest;
+
+ browser.test.assertEq(
+ actualManifestGeckoId,
+ expectedManifestGeckoId,
+ "the add-on manifest should be accessible from the created iframe"
+ );
+
+ let {
+ browser_specific_settings: {
+ gecko: { id: testGetManifestGeckoId },
+ },
+ } = testGetManifest;
+
+ browser.test.assertEq(
+ testGetManifestGeckoId,
+ expectedManifestGeckoId,
+ "GET_MANIFEST() returns manifest data before extension unload"
+ );
+
+ browser.test.sendMessage(name);
+ });
+ }
+
+ function contentScriptIframe() {
+ window.GET_MANIFEST = browser.runtime.getManifest.bind(null);
+
+ window.testGetManifestException = () => {
+ try {
+ window.GET_MANIFEST();
+ } catch (exception) {
+ return String(exception);
+ }
+ };
+
+ let testGetManifest = window.GET_MANIFEST();
+
+ let manifest = browser.runtime.getManifest();
+ let availableAPIs = Object.keys(browser).filter(key => browser[key]);
+
+ browser.runtime.sendMessage({
+ name: "content-script-iframe-loaded",
+ availableAPIs,
+ manifest,
+ testGetManifest,
+ });
+ }
+
+ const ID = "contentscript@tests.mozilla.org";
+ let extensionData = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+ web_accessible_resources: ["content_script_iframe.html"],
+ },
+
+ background,
+
+ files: {
+ "content_script.js"() {
+ let iframe = document.createElement("iframe");
+ iframe.src = browser.runtime.getURL("content_script_iframe.html");
+ document.body.appendChild(iframe);
+ },
+ "content_script_iframe.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script type="text/javascript" src="content_script_iframe.js"></script>
+ </head>
+ </html>`,
+ "content_script_iframe.js": contentScriptIframe,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ await extension.awaitMessage("content-script-iframe-loaded");
+
+ info("testing APIs availability once the extension is unloaded...");
+
+ await contentPage.legacySpawn(null, () => {
+ this.iframeWindow = this.content[0];
+
+ Assert.ok(this.iframeWindow, "content script enabled iframe found");
+ Assert.ok(
+ /content_script_iframe\.html$/.test(this.iframeWindow.location),
+ "the found iframe has the expected URL"
+ );
+ });
+
+ await extension.unload();
+
+ info(
+ "test content script APIs not accessible from the frame once the extension is unloaded"
+ );
+
+ await contentPage.legacySpawn(null, () => {
+ let win = Cu.waiveXrays(this.iframeWindow);
+ ok(
+ !Cu.isDeadWrapper(win.browser),
+ "the API object should not be a dead object"
+ );
+
+ let manifest;
+ let manifestException;
+ try {
+ manifest = win.browser.runtime.getManifest();
+ } catch (e) {
+ manifestException = e;
+ }
+
+ Assert.ok(!manifest, "manifest should be undefined");
+
+ Assert.equal(
+ manifestException.constructor.name,
+ "TypeError",
+ "expected exception received"
+ );
+
+ Assert.ok(
+ manifestException.message.endsWith("win.browser.runtime is undefined"),
+ "expected exception received"
+ );
+
+ let getManifestException = win.testGetManifestException();
+
+ Assert.equal(
+ getManifestException,
+ "TypeError: can't access dead object",
+ "expected exception received"
+ );
+ });
+
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js
new file mode 100644
index 0000000000..6b03f5b0b0
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js
@@ -0,0 +1,433 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+const server = createHttpServer({
+ hosts: ["example.com", "csplog.example.net"],
+});
+server.registerDirectory("/data/", do_get_file("data"));
+
+var gDefaultCSP = `default-src 'self' 'report-sample'; script-src 'self' 'report-sample';`;
+var gCSP = gDefaultCSP;
+const pageContent = `<!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title></title>
+ </head>
+ <body>
+ <img id="testimg">
+ </body>
+ </html>`;
+
+server.registerPathHandler("/plain.html", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html");
+ if (gCSP) {
+ info(`Content-Security-Policy: ${gCSP}`);
+ response.setHeader("Content-Security-Policy", gCSP);
+ }
+ response.write(pageContent);
+});
+
+const BASE_URL = `http://example.com`;
+const pageURL = `${BASE_URL}/plain.html`;
+
+const CSP_REPORT_PATH = "/csp-report.sjs";
+
+function readUTF8InputStream(stream) {
+ let buffer = NetUtil.readInputStream(stream, stream.available());
+ return new TextDecoder().decode(buffer);
+}
+
+server.registerPathHandler(CSP_REPORT_PATH, (request, response) => {
+ response.setStatusLine(request.httpVersion, 204, "No Content");
+ let data = readUTF8InputStream(request.bodyInputStream);
+ Services.obs.notifyObservers(null, "extension-test-csp-report", data);
+});
+
+async function promiseCSPReport(test) {
+ let res = await TestUtils.topicObserved("extension-test-csp-report", test);
+ return JSON.parse(res[1]);
+}
+
+// Test functions loaded into extension content script.
+function testImage(data = {}) {
+ return new Promise(resolve => {
+ let img = window.document.getElementById("testimg");
+ img.onload = () => resolve(true);
+ img.onerror = () => {
+ browser.test.log(`img error: ${img.src}`);
+ resolve(false);
+ };
+ img.src = data.image_url;
+ });
+}
+
+function testFetch(data = {}) {
+ let f = data.content ? content.fetch : fetch;
+ return f(data.url)
+ .then(() => true)
+ .catch(e => {
+ browser.test.assertEq(
+ e.message,
+ "NetworkError when attempting to fetch resource.",
+ "expected fetch failure"
+ );
+ return false;
+ });
+}
+
+async function testEval(data = {}) {
+ try {
+ // eslint-disable-next-line no-eval
+ let ev = data.content ? window.eval : eval;
+ return ev("true");
+ } catch (e) {
+ return false;
+ }
+}
+
+async function testFunction(data = {}) {
+ try {
+ // eslint-disable-next-line no-eval
+ let fn = data.content ? window.Function : Function;
+ let sum = new fn("a", "b", "return a + b");
+ return sum(1, 1);
+ } catch (e) {
+ return 0;
+ }
+}
+
+function testScriptTag(data) {
+ return new Promise(resolve => {
+ let script = document.createElement("script");
+ script.src = data.url;
+ script.onload = () => {
+ resolve(true);
+ };
+ script.onerror = () => {
+ resolve(false);
+ };
+ document.body.appendChild(script);
+ });
+}
+
+async function testHttpRequestUpgraded(data = {}) {
+ let f = data.content ? content.fetch : fetch;
+ return f(data.url)
+ .then(() => "http:")
+ .catch(() => "https:");
+}
+
+async function testWebSocketUpgraded(data = {}) {
+ let ws = data.content ? content.WebSocket : WebSocket;
+ new ws(data.url);
+}
+
+function webSocketUpgradeListenerBackground() {
+ // Catch websocket requests and send the protocol back to be asserted.
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ // Send the protocol back as test result.
+ // This will either be "wss:", "ws:"
+ browser.test.sendMessage("result", new URL(details.url).protocol);
+ return { cancel: true };
+ },
+ { urls: ["wss://example.com/*", "ws://example.com/*"] },
+ ["blocking"]
+ );
+}
+
+// If the violation source is the extension the securitypolicyviolation event is not fired.
+// If the page is the source, the event is fired and both the content script or page scripts
+// will receive the event. If we're expecting a moz-extension report we'll fail in the
+// event listener if we receive a report. Otherwise we want to resolve in the listener to
+// ensure we've received the event for the test.
+function contentScript(report) {
+ return new Promise(resolve => {
+ if (!report || report["document-uri"] === "moz-extension") {
+ resolve();
+ }
+ // eslint-disable-next-line mozilla/balanced-listeners
+ document.addEventListener("securitypolicyviolation", e => {
+ browser.test.assertTrue(
+ e.documentURI !== "moz-extension",
+ `securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}`
+ );
+ resolve();
+ });
+ });
+}
+
+let TESTS = [
+ // Image Tests
+ {
+ description:
+ "Image from content script using default extension csp. Image is allowed.",
+ pageCSP: `${gDefaultCSP} img-src 'none';`,
+ script: testImage,
+ data: { image_url: `${BASE_URL}/data/file_image_good.png` },
+ expect: true,
+ },
+ // Fetch Tests
+ {
+ description: "Fetch url in content script uses default extension csp.",
+ pageCSP: `${gDefaultCSP} connect-src 'none';`,
+ script: testFetch,
+ data: { url: `${BASE_URL}/data/file_image_good.png` },
+ expect: true,
+ },
+ {
+ description: "Fetch full url from content script uses page csp.",
+ pageCSP: `${gDefaultCSP} connect-src 'none';`,
+ script: testFetch,
+ data: {
+ content: true,
+ url: `${BASE_URL}/data/file_image_good.png`,
+ },
+ expect: false,
+ report: {
+ "blocked-uri": `${BASE_URL}/data/file_image_good.png`,
+ "document-uri": `${BASE_URL}/plain.html`,
+ "violated-directive": "connect-src",
+ },
+ },
+
+ // Eval tests.
+ {
+ description: "Eval from content script uses page csp with unsafe-eval.",
+ pageCSP: `default-src 'none'; script-src 'unsafe-eval';`,
+ script: testEval,
+ data: { content: true },
+ expect: true,
+ },
+ {
+ description: "Eval from content script uses page csp.",
+ pageCSP: `default-src 'self' 'report-sample'; script-src 'self';`,
+ version: 3,
+ script: testEval,
+ data: { content: true },
+ expect: false,
+ report: {
+ "blocked-uri": "eval",
+ "document-uri": "http://example.com/plain.html",
+ "violated-directive": "script-src",
+ },
+ },
+ {
+ description: "Eval in content script allowed by v2 csp.",
+ pageCSP: `script-src 'self' 'unsafe-eval';`,
+ script: testEval,
+ expect: true,
+ },
+ {
+ description: "Eval in content script disallowed by v3 csp.",
+ pageCSP: `script-src 'self' 'unsafe-eval';`,
+ version: 3,
+ script: testEval,
+ expect: false,
+ },
+ {
+ description: "Wrapped Eval in content script uses page csp.",
+ pageCSP: `script-src 'self' 'unsafe-eval';`,
+ version: 3,
+ script: async () => {
+ return window.wrappedJSObject.eval("true");
+ },
+ expect: true,
+ },
+ {
+ description: "Wrapped Eval in content script denied by page csp.",
+ pageCSP: `script-src 'self';`,
+ version: 3,
+ script: async () => {
+ try {
+ return window.wrappedJSObject.eval("true");
+ } catch (e) {
+ return false;
+ }
+ },
+ expect: false,
+ },
+
+ {
+ description: "Function from content script uses page csp.",
+ pageCSP: `default-src 'self'; script-src 'self' 'unsafe-eval';`,
+ script: testFunction,
+ data: { content: true },
+ expect: 2,
+ },
+ {
+ description: "Function from content script uses page csp.",
+ pageCSP: `default-src 'self' 'report-sample'; script-src 'self';`,
+ version: 3,
+ script: testFunction,
+ data: { content: true },
+ expect: 0,
+ report: {
+ "blocked-uri": "eval",
+ "document-uri": "http://example.com/plain.html",
+ "violated-directive": "script-src",
+ },
+ },
+ {
+ description: "Function in content script uses extension csp.",
+ pageCSP: `default-src 'self'; script-src 'self' 'unsafe-eval';`,
+ version: 3,
+ script: testFunction,
+ expect: 0,
+ },
+
+ // The javascript url tests are not included as we do not execute those,
+ // aparently even with the urlbar filtering pref flipped.
+ // (browser.urlbar.filter.javascript)
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=866522
+
+ // script tag injection tests
+ {
+ description: "remote script in content script passes in v2",
+ version: 2,
+ pageCSP: "script-src http://example.com:*;",
+ script: testScriptTag,
+ data: { url: `${BASE_URL}/data/file_script_good.js` },
+ expect: true,
+ },
+ {
+ description: "remote script in content script fails in v3",
+ version: 3,
+ pageCSP: "script-src http://example.com:*;",
+ script: testScriptTag,
+ data: { url: `${BASE_URL}/data/file_script_good.js` },
+ expect: false,
+ },
+ {
+ description: "content.WebSocket in content script is affected by page csp.",
+ version: 2,
+ pageCSP: `upgrade-insecure-requests;`,
+ data: { content: true, url: "ws://example.com/ws_dummy" },
+ script: testWebSocketUpgraded,
+ expect: "wss:", // we expect the websocket to be upgraded.
+ backgroundScript: webSocketUpgradeListenerBackground,
+ },
+ {
+ description: "WebSocket in content script is not affected by page csp.",
+ version: 2,
+ pageCSP: `upgrade-insecure-requests;`,
+ data: { url: "ws://example.com/ws_dummy" },
+ script: testWebSocketUpgraded,
+ expect: "ws:", // we expect the websocket to not be upgraded.
+ backgroundScript: webSocketUpgradeListenerBackground,
+ },
+ {
+ description: "WebSocket in content script is not affected by page csp. v3",
+ version: 3,
+ pageCSP: `upgrade-insecure-requests;`,
+ data: { url: "ws://example.com/ws_dummy" },
+ script: testWebSocketUpgraded,
+ // TODO bug 1766813: MV3+WebSocket should use content script CSP.
+ expect: "wss:", // TODO: we expect the websocket to not be upgraded (ws:).
+ backgroundScript: webSocketUpgradeListenerBackground,
+ },
+ {
+ description: "Http request in content script is not affected by page csp.",
+ version: 2,
+ pageCSP: `upgrade-insecure-requests;`,
+ data: { url: "http://example.com/plain.html" },
+ script: testHttpRequestUpgraded,
+ expect: "http:", // we expect the request to not be upgraded.
+ },
+ {
+ description:
+ "Http request in content script is not affected by page csp. v3",
+ version: 3,
+ pageCSP: `upgrade-insecure-requests;`,
+ data: { url: "http://example.com/plain.html" },
+ script: testHttpRequestUpgraded,
+ // TODO bug 1766813: MV3+fetch should use content script CSP.
+ expect: "https:", // TODO: we expect the request to not be upgraded (http:).
+ },
+ {
+ description: "content.fetch in content script is affected by page csp.",
+ version: 2,
+ pageCSP: `upgrade-insecure-requests;`,
+ data: { content: true, url: "http://example.com/plain.html" },
+ script: testHttpRequestUpgraded,
+ expect: "https:", // we expect the request to be upgraded.
+ },
+];
+
+async function runCSPTest(test) {
+ // Set the CSP for the page loaded into the tab.
+ gCSP = `${test.pageCSP || gDefaultCSP} report-uri ${CSP_REPORT_PATH}`;
+ let data = {
+ manifest: {
+ manifest_version: test.version || 2,
+ content_scripts: [
+ {
+ matches: ["http://*/plain.html"],
+ run_at: "document_idle",
+ js: ["content_script.js"],
+ },
+ ],
+ permissions: ["webRequest", "webRequestBlocking"],
+ host_permissions: ["<all_urls>"],
+ granted_host_permissions: true,
+ background: { scripts: ["background.js"] },
+ },
+ temporarilyInstalled: true,
+ files: {
+ "content_script.js": `
+ (${contentScript})(${JSON.stringify(test.report)}).then(() => {
+ browser.test.sendMessage("violationEvent");
+ });
+ (${test.script})(${JSON.stringify(test.data)}).then(result => {
+ if(result !== undefined) {
+ browser.test.sendMessage("result", result);
+ }
+ });
+ `,
+ "background.js": `(${test.backgroundScript || (() => {})})()`,
+ ...test.files,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(data);
+ await extension.startup();
+
+ let reportPromise = test.report && promiseCSPReport();
+ let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
+
+ info(`running: ${test.description}`);
+ await extension.awaitMessage("violationEvent");
+
+ let result = await extension.awaitMessage("result");
+ equal(result, test.expect, test.description);
+
+ if (test.report) {
+ let report = await reportPromise;
+ for (let key of Object.keys(test.report)) {
+ equal(
+ report["csp-report"][key],
+ test.report[key],
+ `csp-report ${key} matches`
+ );
+ }
+ }
+
+ await extension.unload();
+ await contentPage.close();
+ clearCache();
+}
+
+add_task(async function test_contentscript_csp() {
+ for (let test of TESTS) {
+ await runCSPTest(test);
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js
new file mode 100644
index 0000000000..a75c397b8c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js
@@ -0,0 +1,48 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_content_script_css() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy"],
+ css: ["content.css"],
+ run_at: "document_start",
+ },
+ ],
+ },
+
+ files: {
+ "content.css": "body { max-width: 42px; }",
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+
+ function task() {
+ let style = this.content.getComputedStyle(this.content.document.body);
+ return style.maxWidth;
+ }
+
+ let maxWidth = await contentPage.spawn([], task);
+ equal(maxWidth, "42px", "Stylesheet correctly applied");
+
+ await extension.unload();
+
+ maxWidth = await contentPage.spawn([], task);
+ equal(maxWidth, "none", "Stylesheet correctly removed");
+
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_dynamic_registration.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_dynamic_registration.js
new file mode 100644
index 0000000000..0133b5d86c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_dynamic_registration.js
@@ -0,0 +1,205 @@
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell, to use
+// the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+// Do not use preallocated processes.
+Services.prefs.setBoolPref("dom.ipc.processPrelaunch.enabled", false);
+// This is needed for Android.
+Services.prefs.setIntPref("dom.ipc.keepProcessesAlive.web", 0);
+
+const makeExtension = ({ background, manifest }) => {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ ...manifest,
+ permissions:
+ manifest.manifest_version === 3 ? ["scripting"] : ["http://*/*/*.html"],
+ },
+ temporarilyInstalled: true,
+ background,
+ files: {
+ "script.js": () => {
+ browser.test.sendMessage(
+ `script-ran: ${location.pathname.split("/").pop()}`
+ );
+ },
+ "inject_browser.js": () => {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ // Inject `browser.test.sendMessage()` so that it can be used in the
+ // `script.js` defined above when using "user scripts".
+ script.defineGlobals({
+ browser: {
+ test: {
+ sendMessage(msg) {
+ browser.test.sendMessage(msg);
+ },
+ },
+ },
+ });
+ });
+ },
+ },
+ });
+};
+
+const verifyRegistrationWithNewProcess = async extension => {
+ // We override the `broadcast()` method to reliably verify Bug 1756495: when
+ // a new process is spawned while we register a content script, the script
+ // should be correctly registered and executed in this new process. Below,
+ // when we receive the `Extension:RegisterContentScripts`, we open a new tab
+ // (which is the "new process") and then we invoke the original "broadcast
+ // logic". The expected result is that the content script registered by the
+ // extension will run.
+ const originalBroadcast = Extension.prototype.broadcast;
+
+ let broadcastCalledCount = 0;
+ let secondContentPage;
+
+ extension.extension.broadcast = async function broadcast(msg, data) {
+ if (msg !== "Extension:RegisterContentScripts") {
+ return originalBroadcast.call(this, msg, data);
+ }
+
+ broadcastCalledCount++;
+ Assert.equal(
+ 1,
+ broadcastCalledCount,
+ "broadcast override should be called once"
+ );
+
+ await originalBroadcast.call(this, msg, data);
+
+ Assert.equal(extension.id, data.id, "got expected extension ID");
+ Assert.equal(1, data.scripts.length, "expected 1 script to register");
+ Assert.ok(
+ data.scripts[0].options.jsPaths[0].endsWith("script.js"),
+ "got expected js file"
+ );
+
+ const newPids = [];
+ const topic = "ipc:content-created";
+
+ let obs = (subject, topic, data) => {
+ newPids.push(parseInt(data, 10));
+ };
+ Services.obs.addObserver(obs, topic);
+
+ secondContentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/dummy_page.html`
+ );
+
+ const { childID } =
+ secondContentPage.browsingContext.currentWindowGlobal.domProcess;
+
+ Services.obs.removeObserver(obs, topic);
+
+ // We expect to have a new process created for `secondContentPage`.
+ Assert.ok(
+ newPids.includes(childID),
+ `expected PID ${childID} to be in [${newPids.join(", ")}])`
+ );
+ };
+
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await Promise.all([
+ extension.awaitMessage("script-ran: file_sample.html"),
+ extension.awaitMessage("script-ran: dummy_page.html"),
+ ]);
+
+ // Unload extension first to avoid an issue on Windows platforms.
+ await extension.unload();
+ await contentPage.close();
+ await secondContentPage.close();
+};
+
+add_task(
+ {
+ pref_set: [["extensions.manifestV3.enabled", true]],
+ },
+ async function test_scripting_registerContentScripts() {
+ let extension = makeExtension({
+ manifest: {
+ manifest_version: 3,
+ host_permissions: ["<all_urls>"],
+ granted_host_permissions: true,
+ },
+ async background() {
+ const script = {
+ id: "a-script",
+ js: ["script.js"],
+ matches: ["http://*/*/*.html"],
+ persistAcrossSessions: false,
+ };
+
+ await browser.scripting.registerContentScripts([script]);
+
+ browser.test.sendMessage("background-done");
+ },
+ });
+
+ await verifyRegistrationWithNewProcess(extension);
+ }
+);
+
+add_task(
+ {
+ // We don't have WebIDL bindings for `browser.contentScripts`.
+ skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(),
+ },
+ async function test_contentScripts_register() {
+ let extension = makeExtension({
+ manifest: {
+ manifest_version: 2,
+ },
+ async background() {
+ await browser.contentScripts.register({
+ js: [{ file: "script.js" }],
+ matches: ["http://*/*/*.html"],
+ });
+
+ browser.test.sendMessage("background-done");
+ },
+ });
+
+ await verifyRegistrationWithNewProcess(extension);
+ }
+);
+
+add_task(
+ {
+ // We don't have WebIDL bindings for `browser.userScripts`.
+ skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(),
+ },
+ async function test_userScripts_register() {
+ let extension = makeExtension({
+ manifest: {
+ manifest_version: 2,
+ user_scripts: {
+ api_script: "inject_browser.js",
+ },
+ },
+ async background() {
+ await browser.userScripts.register({
+ js: [{ file: "script.js" }],
+ matches: ["http://*/*/*.html"],
+ });
+
+ browser.test.sendMessage("background-done");
+ },
+ });
+
+ await verifyRegistrationWithNewProcess(extension);
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js
new file mode 100644
index 0000000000..6fae3b838a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js
@@ -0,0 +1,150 @@
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+const TEST_URL_1 = `${BASE_URL}/file_sample.html`;
+const TEST_URL_2 = `${BASE_URL}/file_content_script_errors.html`;
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+add_task(async function test_cached_contentscript_on_document_start() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ // Use distinct content scripts as some will throw and would prevent executing the next script
+ {
+ matches: ["http://*/*/file_content_script_errors.html"],
+ js: ["script1.js"],
+ run_at: "document_start",
+ },
+ {
+ matches: ["http://*/*/file_content_script_errors.html"],
+ js: ["script2.js"],
+ run_at: "document_start",
+ },
+ {
+ matches: ["http://*/*/file_content_script_errors.html"],
+ js: ["script3.js"],
+ run_at: "document_start",
+ },
+ {
+ matches: ["http://*/*/file_content_script_errors.html"],
+ js: ["script4.js"],
+ run_at: "document_start",
+ },
+ {
+ matches: ["http://*/*/file_content_script_errors.html"],
+ js: ["script5.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+
+ files: {
+ "script1.js": `
+ throw new Error("Object exception");
+ `,
+ "script2.js": `
+ throw "String exception";
+ `,
+ "script3.js": `
+ undefinedSymbol();
+ `,
+ "script4.js": `
+ )
+ `,
+ "script5.js": `
+ Promise.reject("rejected promise");
+
+ (async () => {
+ /* make the async, really async */
+ await new Promise(r => setTimeout(r, 0));
+ throw new Error("async function exception");
+ })();
+
+ setTimeout(() => {
+ asyncUndefinedSymbol();
+ });
+
+ /* Use a delay in order to resume test execution after these async errors */
+ setTimeout(() => {
+ browser.test.sendMessage("content-script-loaded");
+ }, 500);
+ `,
+ },
+ });
+
+ // Error messages, in roughly the order they appear above.
+ let expectedMessages = [
+ "Error: Object exception",
+ "uncaught exception: String exception",
+ "ReferenceError: undefinedSymbol is not defined",
+ "SyntaxError: expected expression, got ')'",
+ "uncaught exception: rejected promise",
+ "Error: async function exception",
+ "ReferenceError: asyncUndefinedSymbol is not defined",
+ ];
+
+ await extension.startup();
+
+ // Load a first page in order to be able to register a console listener in the content process.
+ // This has to be done in the same domain of the second page to stay in the same process.
+ let contentPage = await ExtensionTestUtils.loadContentPage(TEST_URL_1);
+
+ // Listen to the errors logged in the content process.
+ let errorsPromise = ContentTask.spawn(contentPage.browser, {}, async () => {
+ return new Promise(resolve => {
+ function listener(error0) {
+ let error = error0.QueryInterface(Ci.nsIScriptError);
+
+ // Ignore errors from ExtensionContent.jsm
+ if (!error.innerWindowID) {
+ return;
+ }
+
+ this.collectedErrors.push({
+ innerWindowID: error.innerWindowID,
+ message: error.errorMessage,
+ });
+ if (this.collectedErrors.length == 7) {
+ Services.console.unregisterListener(this);
+ resolve(this.collectedErrors);
+ }
+ }
+ listener.collectedErrors = [];
+ Services.console.registerListener(listener);
+ });
+ });
+
+ // Reload the page and check that the cached content script is still able to
+ // run on document_start.
+ await contentPage.loadURL(TEST_URL_2);
+
+ let errors = await errorsPromise;
+
+ await extension.awaitMessage("content-script-loaded");
+
+ equal(errors.length, 7);
+ let messages = [];
+ for (const { innerWindowID, message } of errors) {
+ equal(
+ innerWindowID,
+ contentPage.browser.innerWindowID,
+ `Message ${message} has the innerWindowID set`
+ );
+
+ messages.push(message);
+ }
+
+ messages.sort();
+ expectedMessages.sort();
+ Assert.deepEqual(messages, expectedMessages, "Got the expected errors");
+
+ await extension.unload();
+
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js
new file mode 100644
index 0000000000..ec3b11ee7d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js
@@ -0,0 +1,98 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_contentscript_exportHelpers() {
+ function contentScript() {
+ browser.test.assertTrue(typeof cloneInto === "function");
+ browser.test.assertTrue(typeof createObjectIn === "function");
+ browser.test.assertTrue(typeof exportFunction === "function");
+
+ /* globals exportFunction, precisePi, reportPi */
+ let value = 3.14;
+ exportFunction(() => value, window, { defineAs: "precisePi" });
+
+ browser.test.assertEq(
+ "undefined",
+ typeof precisePi,
+ "exportFunction should export to the page's scope only"
+ );
+
+ browser.test.assertEq(
+ "undefined",
+ typeof window.precisePi,
+ "exportFunction should export to the page's scope only"
+ );
+
+ let results = [];
+ exportFunction(pi => results.push(pi), window, { defineAs: "reportPi" });
+
+ let s = document.createElement("script");
+ s.textContent = `(${function () {
+ let result1 = "unknown 1";
+ let result2 = "unknown 2";
+ try {
+ result1 = precisePi();
+ } catch (e) {
+ result1 = "err:" + e;
+ }
+ try {
+ result2 = window.precisePi();
+ } catch (e) {
+ result2 = "err:" + e;
+ }
+ reportPi(result1);
+ reportPi(result2);
+ }})();`;
+
+ document.documentElement.appendChild(s);
+ // Inline script ought to run synchronously.
+
+ browser.test.assertEq(
+ 3.14,
+ results[0],
+ "exportFunction on window should define a global function"
+ );
+ browser.test.assertEq(
+ 3.14,
+ results[1],
+ "exportFunction on window should export a property to window."
+ );
+
+ browser.test.assertEq(
+ 2,
+ results.length,
+ "Expecting the number of results to match the number of method calls"
+ );
+
+ browser.test.notifyPass("export helper test completed");
+ }
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ js: ["contentscript.js"],
+ matches: ["http://example.com/data/file_sample.html"],
+ run_at: "document_start",
+ },
+ ],
+ },
+
+ files: {
+ "contentscript.js": contentScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ await extension.awaitFinish("export helper test completed");
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_importmap.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_importmap.js
new file mode 100644
index 0000000000..c7762f3afe
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_importmap.js
@@ -0,0 +1,124 @@
+"use strict";
+
+// Currently import maps are not supported for web extensions, neither for
+// content scripts nor moz-extension documents.
+// For content scripts that's because they use their own sandbox module loaders,
+// which is different from the DOM module loader.
+// As for moz-extension documents, that's because inline script tags is not
+// allowed by CSP. (Currently import maps can be only added through inline
+// script tag.)
+//
+// This test is used to verified import maps are not supported for web
+// extensions.
+// See Bug 1765275: Enable Import maps for web extension content scripts.
+Services.prefs.setBoolPref("dom.importMaps.enabled", true);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+const importMapString = `
+ <script type="importmap">
+ {
+ "imports": {
+ "simple": "./simple.js",
+ "simple2": "./simple2.js"
+ }
+ }
+ </script>`;
+
+const importMapHtml = `
+ <!DOCTYPE html>
+ <html>
+ <meta charset=utf-8>
+ <title>Test a simple import map in normal webpage</title>
+ <body>
+ ${importMapString}
+ </body></html>`;
+
+// page.html will load page.js, which will call import();
+const pageHtml = `
+ <!DOCTYPE html>
+ <html>
+ <meta charset=utf-8>
+ <title>Test a simple import map in moz-extension documents</title>
+ <body>
+ ${importMapString}
+ <script src="page.js"></script>
+ </body></html>`;
+
+const simple2JS = `export let foo = 2;`;
+
+server.registerPathHandler("/importmap.html", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write(importMapHtml);
+});
+
+server.registerPathHandler("/simple.js", (request, response) => {
+ ok(false, "Unexpected request to /simple.js");
+});
+
+server.registerPathHandler("/simple2.js", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/javascript", false);
+ response.write(simple2JS);
+});
+
+add_task(async function test_importMaps_not_supported() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/importmap.html"],
+ js: ["main.js"],
+ },
+ ],
+ },
+
+ files: {
+ "main.js": async function () {
+ // Content scripts shouldn't be able to use the bare specifier from
+ // the import map.
+ await browser.test.assertRejects(
+ import("simple"),
+ /The specifier “simple” was a bare specifier/,
+ `should reject import("simple")`
+ );
+
+ browser.test.sendMessage("done");
+ },
+ "page.html": pageHtml,
+ "page.js": async function () {
+ await browser.test.assertRejects(
+ import("simple"),
+ /The specifier “simple” was a bare specifier/,
+ `should reject import("simple")`
+ );
+ browser.test.sendMessage("page-done");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/importmap.html"
+ );
+ await extension.awaitMessage("done");
+
+ await contentPage.spawn([], async () => {
+ // Import maps should work for documents.
+ let promise = content.eval(`import("simple2")`);
+ let mod = (await promise.wrappedJSObject).wrappedJSObject;
+ Assert.equal(mod.foo, 2, "mod.foo should be 2");
+ });
+
+ // moz-extension documents doesn't allow inline scripts, so the import map
+ // script tag won't be processed.
+ let url = `moz-extension://${extension.uuid}/page.html`;
+ let page = await ExtensionTestUtils.loadContentPage(url, { extension });
+ await extension.awaitMessage("page-done");
+
+ await page.close();
+ await extension.unload();
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js
new file mode 100644
index 0000000000..e813b46ca0
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js
@@ -0,0 +1,43 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerPathHandler("/dummyFrame", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write("");
+});
+
+add_task(async function content_script_in_background_frame() {
+ async function background() {
+ const FRAME_URL = "http://example.com:8888/dummyFrame";
+ await browser.contentScripts.register({
+ matches: ["http://example.com/dummyFrame"],
+ js: [{ file: "contentscript.js" }],
+ allFrames: true,
+ });
+
+ let f = document.createElement("iframe");
+ f.src = FRAME_URL;
+ document.body.appendChild(f);
+ }
+
+ function contentScript() {
+ browser.test.log(`Running content script at ${document.URL}`);
+ browser.test.sendMessage("done_in_content_script");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://example.com/*"],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ },
+ background,
+ });
+ await extension.startup();
+ await extension.awaitMessage("done_in_content_script");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js
new file mode 100644
index 0000000000..ca37e2e951
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js
@@ -0,0 +1,102 @@
+"use strict";
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write(
+ `<script>
+ // Clobber the JSON API to allow us to confirm that the page's value for
+ // the "JSON" object does not affect the content script's JSON API.
+ window.JSON = new String("overridden by page");
+ window.objFromPage = { serializeMe: "thanks" };
+ window.objWithToJSON = { toJSON: () => "toJSON ran", should_not_see: 1 };
+ </script>
+ `
+ );
+});
+
+async function test_JSON_parse_and_stringify({ manifest_version }) {
+ let extension = ExtensionTestUtils.loadExtension({
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ manifest: {
+ manifest_version,
+ granted_host_permissions: true, // Test-only: grant permissions in MV3.
+ host_permissions: ["http://example.com/"], // Work-around for bug 1766752.
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy"],
+ run_at: "document_end",
+ js: ["contentscript.js"],
+ },
+ ],
+ },
+ files: {
+ "contentscript.js"() {
+ let json = `{"a":[123,true,null]}`;
+ browser.test.assertEq(
+ JSON.stringify({ a: [123, true, null] }),
+ json,
+ "JSON.stringify with basic values"
+ );
+ let parsed = JSON.parse(json);
+ browser.test.assertTrue(
+ parsed instanceof Object,
+ "Parsed JSON is an Object"
+ );
+ browser.test.assertTrue(
+ parsed.a instanceof Array,
+ "Parsed JSON has an Array"
+ );
+ browser.test.assertEq(
+ JSON.stringify(parsed),
+ json,
+ "JSON.stringify for parsed JSON returns original input"
+ );
+ browser.test.assertEq(
+ JSON.stringify({ toJSON: () => "overridden", hideme: true }),
+ `"overridden"`,
+ "JSON.parse with toJSON method"
+ );
+
+ browser.test.assertEq(
+ JSON.stringify(window.wrappedJSObject.objFromPage),
+ `{"serializeMe":"thanks"}`,
+ "JSON.parse with value from the page"
+ );
+
+ browser.test.assertEq(
+ JSON.stringify(window.wrappedJSObject.objWithToJSON),
+ `"toJSON ran"`,
+ "JSON.parse with object with toJSON method from the page"
+ );
+
+ browser.test.assertTrue(JSON === globalThis.JSON, "JSON === this.JSON");
+ browser.test.assertTrue(JSON === window.JSON, "JSON === window.JSON");
+ browser.test.assertEq(
+ "overridden by page",
+ window.wrappedJSObject.JSON.toString(),
+ "page's JSON object is still the original value (overridden by page)"
+ );
+ browser.test.sendMessage("done");
+ },
+ },
+ });
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+ await extension.awaitMessage("done");
+ await contentPage.close();
+ await extension.unload();
+}
+
+add_task(async function test_JSON_apis_MV2() {
+ await test_JSON_parse_and_stringify({ manifest_version: 2 });
+});
+
+add_task(async function test_JSON_apis_MV3() {
+ await test_JSON_parse_and_stringify({ manifest_version: 3 });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js
new file mode 100644
index 0000000000..b67ab1bcd9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js
@@ -0,0 +1,277 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+server.registerPathHandler("/script.js", (request, response) => {
+ ok(false, "Unexpected request to /script.js");
+});
+
+/* eslint-disable no-unsanitized/method, no-eval, no-implied-eval */
+
+const MODULE1 = `
+ import {foo} from "./module2.js";
+ export let bar = foo;
+
+ let count = 0;
+
+ export function counter () { return count++; }
+`;
+
+const MODULE2 = `export let foo = 2;`;
+
+add_task(async function test_disallowed_import() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy"],
+ js: ["main.js"],
+ },
+ ],
+ },
+
+ files: {
+ "main.js": async function () {
+ let disallowedURLs = [
+ "data:text/javascript,void 0",
+ "javascript:void 0",
+ "http://example.com/script.js",
+ URL.createObjectURL(
+ new Blob(["void 0", { type: "text/javascript" }])
+ ),
+ ];
+
+ for (let url of disallowedURLs) {
+ await browser.test.assertRejects(
+ import(url),
+ /error loading dynamically imported module/,
+ `should reject import("${url}")`
+ );
+ }
+
+ browser.test.sendMessage("done");
+ },
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+ await extension.awaitMessage("done");
+ await extension.unload();
+ await contentPage.close();
+});
+
+add_task(async function test_normal_import() {
+ Services.prefs.setBoolPref("extensions.content_web_accessible.enabled", true);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy"],
+ js: ["main.js"],
+ },
+ ],
+ },
+
+ files: {
+ "main.js": async function () {
+ /* global exportFunction */
+ const url = browser.runtime.getURL("module1.js");
+
+ await browser.test.assertRejects(
+ import(url),
+ /error loading dynamically imported module/,
+ "Cannot import script that is not web-accessible from page context"
+ );
+
+ await browser.test.assertRejects(
+ window.eval(`import("${url}")`),
+ /error loading dynamically imported module/,
+ "Cannot import script that is not web-accessible from page context"
+ );
+
+ let promise = new Promise((resolve, reject) => {
+ exportFunction(resolve, window, { defineAs: "resolve" });
+ exportFunction(reject, window, { defineAs: "reject" });
+ });
+
+ window.setTimeout(`import("${url}").then(resolve, reject)`, 0);
+
+ await browser.test.assertRejects(
+ promise,
+ /error loading dynamically imported module/,
+ "Cannot import script that is not web-accessible from page context"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ "module1.js": MODULE1,
+ "module2.js": MODULE2,
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+
+ await extension.awaitMessage("done");
+
+ // Web page can not import non-web-accessible files.
+ await contentPage.spawn([extension.uuid], async uuid => {
+ let files = ["main.js", "module1.js", "module2.js"];
+
+ for (let file of files) {
+ let url = `moz-extension://${uuid}/${file}`;
+ await Assert.rejects(
+ content.eval(`import("${url}")`),
+ /error loading dynamically imported module/,
+ "Cannot import script that is not web-accessible"
+ );
+ }
+ });
+
+ await extension.unload();
+ await contentPage.close();
+});
+
+add_task(async function test_import_web_accessible() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy"],
+ js: ["main.js"],
+ },
+ ],
+ web_accessible_resources: ["module1.js", "module2.js"],
+ },
+
+ files: {
+ "main.js": async function () {
+ let mod = await import(browser.runtime.getURL("module1.js"));
+ browser.test.assertEq(mod.bar, 2);
+ browser.test.assertEq(mod.counter(), 0);
+ browser.test.sendMessage("done");
+ },
+ "module1.js": MODULE1,
+ "module2.js": MODULE2,
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+ await extension.awaitMessage("done");
+
+ // Web page can import web-accessible files,
+ // even after WebExtension imported the same files.
+ await contentPage.spawn([extension.uuid], async uuid => {
+ let base = `moz-extension://${uuid}`;
+
+ await Assert.rejects(
+ content.eval(`import("${base}/main.js")`),
+ /error loading dynamically imported module/,
+ "Cannot import script that is not web-accessible"
+ );
+
+ let promise = content.eval(`import("${base}/module1.js")`);
+ let mod = (await promise.wrappedJSObject).wrappedJSObject;
+ Assert.equal(mod.bar, 2, "exported value should match");
+ Assert.equal(mod.counter(), 0, "Counter should be fresh");
+ Assert.equal(mod.counter(), 1, "Counter should be fresh");
+
+ promise = content.eval(`import("${base}/module2.js")`);
+ mod = (await promise.wrappedJSObject).wrappedJSObject;
+ Assert.equal(mod.foo, 2, "exported value should match");
+ });
+
+ await extension.unload();
+ await contentPage.close();
+});
+
+add_task(async function test_import_web_accessible_after_page() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy"],
+ js: ["main.js"],
+ },
+ ],
+ web_accessible_resources: ["module1.js", "module2.js"],
+ },
+
+ files: {
+ "main.js": async function () {
+ browser.test.onMessage.addListener(async msg => {
+ browser.test.assertEq(msg, "import");
+
+ const url = browser.runtime.getURL("module1.js");
+ let mod = await import(url);
+ browser.test.assertEq(mod.bar, 2);
+ browser.test.assertEq(mod.counter(), 0, "Counter should be fresh");
+
+ let promise = window.eval(`import("${url}")`);
+ let mod2 = (await promise.wrappedJSObject).wrappedJSObject;
+ browser.test.assertEq(
+ mod2.counter(),
+ 2,
+ "Counter should have been incremented by page"
+ );
+
+ browser.test.sendMessage("done");
+ });
+ browser.test.sendMessage("ready");
+ },
+ "module1.js": MODULE1,
+ "module2.js": MODULE2,
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+ await extension.awaitMessage("ready");
+
+ // The web page imports the web-accessible files first,
+ // when the WebExtension imports the same file, they should
+ // not be shared.
+ await contentPage.spawn([extension.uuid], async uuid => {
+ let base = `moz-extension://${uuid}`;
+
+ await Assert.rejects(
+ content.eval(`import("${base}/main.js")`),
+ /error loading dynamically imported module/,
+ "Cannot import script that is not web-accessible"
+ );
+
+ let promise = content.eval(`import("${base}/module1.js")`);
+ let mod = (await promise.wrappedJSObject).wrappedJSObject;
+ Assert.equal(mod.bar, 2, "exported value should match");
+ Assert.equal(mod.counter(), 0);
+ Assert.equal(mod.counter(), 1);
+
+ promise = content.eval(`import("${base}/module2.js")`);
+ mod = (await promise.wrappedJSObject).wrappedJSObject;
+ Assert.equal(mod.foo, 2, "exported value should match");
+ });
+
+ extension.sendMessage("import");
+
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js
new file mode 100644
index 0000000000..2546b257fb
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js
@@ -0,0 +1,71 @@
+"use strict";
+
+const server = createHttpServer({
+ hosts: ["a.example.com", "b.example.com", "c.example.com"],
+});
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_perf_observers_cors() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://b.example.com/"],
+ content_scripts: [
+ {
+ matches: ["http://a.example.com/data/file_sample.html"],
+ js: ["cs.js"],
+ },
+ ],
+ },
+ files: {
+ "cs.js"() {
+ let obs = new window.PerformanceObserver(list => {
+ list.getEntries().forEach(e => {
+ browser.test.sendMessage("observed", {
+ url: e.name,
+ time: e.connectEnd,
+ size: e.encodedBodySize,
+ });
+ });
+ });
+ obs.observe({ entryTypes: ["resource"] });
+
+ let b = document.createElement("link");
+ b.rel = "stylesheet";
+
+ // Simulate page including a cross-origin resource from b.example.com.
+ b.wrappedJSObject.href = "http://b.example.com/data/file_download.txt";
+ document.head.appendChild(b);
+
+ let c = document.createElement("link");
+ c.rel = "stylesheet";
+
+ // Simulate page including a cross-origin resource from c.example.com.
+ c.wrappedJSObject.href = "http://c.example.com/data/file_download.txt";
+ document.head.appendChild(c);
+ },
+ },
+ });
+
+ let page = await ExtensionTestUtils.loadContentPage(
+ "http://a.example.com/data/file_sample.html"
+ );
+ await extension.startup();
+
+ let b = await extension.awaitMessage("observed");
+ let c = await extension.awaitMessage("observed");
+
+ if (b.url.startsWith("http://c.")) {
+ [c, b] = [b, c];
+ }
+
+ ok(b.url.startsWith("http://b."), "Observed resource from b.example.com");
+ ok(b.time > 0, "connectionEnd available from b.example.com");
+ equal(b.size, 46, "encodedBodySize available from b.example.com");
+
+ ok(c.url.startsWith("http://c."), "Observed resource from c.example.com");
+ equal(c.time, 0, "connectionEnd == 0 from c.example.com");
+ equal(c.size, 0, "encodedBodySize == 0 from c.example.com");
+
+ await extension.unload();
+ await page.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_change.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_change.js
new file mode 100644
index 0000000000..842994858e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_change.js
@@ -0,0 +1,104 @@
+"use strict";
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+const server = createHttpServer({ hosts: ["example.com", "example.net"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+const HOSTS = ["http://example.com/*", "http://example.net/*"];
+
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+function grantOptional({ extension: ext }, origins) {
+ return ExtensionPermissions.add(ext.id, { origins, permissions: [] }, ext);
+}
+
+function revokeOptional({ extension: ext }, origins) {
+ return ExtensionPermissions.remove(ext.id, { origins, permissions: [] }, ext);
+}
+
+function makeExtension(id, content_scripts) {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+
+ browser_specific_settings: { gecko: { id } },
+ content_scripts,
+
+ permissions: ["scripting"],
+ host_permissions: HOSTS,
+ },
+ files: {
+ "cs.js"() {
+ browser.test.log(`${browser.runtime.id} script on ${location.host}`);
+ browser.test.sendMessage(`${browser.runtime.id}_on_${location.host}`);
+ },
+ },
+ background() {
+ browser.test.onMessage.addListener(async (msg, origins) => {
+ browser.test.log(`${browser.runtime.id} registering content scripts`);
+ await browser.scripting.registerContentScripts([
+ {
+ id: "cs1",
+ persistAcrossSessions: false,
+ matches: origins,
+ js: ["cs.js"],
+ },
+ ]);
+ browser.test.sendMessage("done");
+ });
+ },
+ });
+}
+
+// Test that content scripts in MV3 enforce origin permissions.
+// Test granted optional permissions are available in newly spawned processes.
+add_task(async function test_contentscript_mv3_permissions() {
+ // Alpha lists content scripts in the manifest.
+ let alpha = makeExtension("alpha@test", [{ matches: HOSTS, js: ["cs.js"] }]);
+ let beta = makeExtension("beta@test");
+
+ await grantOptional(alpha, HOSTS);
+ await grantOptional(beta, ["http://example.net/*"]);
+ info("Granted initial permissions for both.");
+
+ await alpha.startup();
+ await beta.startup();
+
+ // Beta registers same content scripts using the scripting api.
+ beta.sendMessage("register", HOSTS);
+ await beta.awaitMessage("done");
+
+ // Only Alpha has origin permissions for example.com.
+ {
+ let page = await ExtensionTestUtils.loadContentPage(
+ `http://example.com/data/file_sample.html`
+ );
+ info("Loaded a page from example.com.");
+
+ await alpha.awaitMessage("alpha@test_on_example.com");
+ info("Got a message from alpha@test on example.com.");
+ await page.close();
+ }
+
+ await revokeOptional(alpha, ["http://example.net/*"]);
+ info("Revoked example.net permissions from Alpha.");
+
+ // Now only Beta has origin permissions for example.net.
+ {
+ let page = await ExtensionTestUtils.loadContentPage(
+ `http://example.net/data/file_sample.html`
+ );
+ info("Loaded a page from example.net.");
+
+ await beta.awaitMessage("beta@test_on_example.net");
+ info("Got a message from beta@test on example.net.");
+ await page.close();
+ }
+
+ info("Done, unloading Alpha and Beta.");
+ await beta.unload();
+ await alpha.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js
new file mode 100644
index 0000000000..f9c1b360a0
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js
@@ -0,0 +1,87 @@
+"use strict";
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+const server = createHttpServer({ hosts: ["example.com", "example.net"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+function grantOptional({ extension: ext }, origins) {
+ return ExtensionPermissions.add(ext.id, { origins, permissions: [] }, ext);
+}
+
+function revokeOptional({ extension: ext }, origins) {
+ return ExtensionPermissions.remove(ext.id, { origins, permissions: [] }, ext);
+}
+
+// Test granted optional permissions work with XHR/fetch in new processes.
+add_task(
+ {
+ pref_set: [["dom.ipc.keepProcessesAlive.extension", 0]],
+ },
+ async function test_fetch_origin_permissions_change() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ host_permissions: ["http://example.com/*"],
+ optional_permissions: ["http://example.net/*"],
+ },
+ files: {
+ "page.js"() {
+ fetch("http://example.net/data/file_sample.html")
+ .then(req => req.text())
+ .then(text => browser.test.sendMessage("done", { text }))
+ .catch(e => browser.test.sendMessage("done", { error: e.message }));
+ },
+ "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`,
+ },
+ });
+
+ await extension.startup();
+
+ let osPid;
+ {
+ // Grant permissions before extension process exists.
+ await grantOptional(extension, ["http://example.net/*"]);
+
+ let page = await ExtensionTestUtils.loadContentPage(
+ extension.extension.baseURI.resolve("page.html")
+ );
+
+ let { text } = await extension.awaitMessage("done");
+ ok(text.includes("Sample text"), "Can read from granted optional host.");
+
+ osPid = page.browsingContext.currentWindowGlobal.osPid;
+ await page.close();
+ }
+
+ // Release the extension process so that next part starts a new one.
+ Services.ppmm.releaseCachedProcesses();
+
+ {
+ // Revoke permissions and confirm fetch fails.
+ await revokeOptional(extension, ["http://example.net/*"]);
+
+ let page = await ExtensionTestUtils.loadContentPage(
+ extension.extension.baseURI.resolve("page.html")
+ );
+
+ let { error } = await extension.awaitMessage("done");
+ ok(error.includes("NetworkError"), `Expected error: ${error}`);
+
+ if (WebExtensionPolicy.useRemoteWebExtensions) {
+ notEqual(
+ osPid,
+ page.browsingContext.currentWindowGlobal.osPid,
+ "Second part of the test used a new process."
+ );
+ }
+
+ await page.close();
+ }
+
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js
new file mode 100644
index 0000000000..d775bb2cfb
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js
@@ -0,0 +1,149 @@
+"use strict";
+
+function makeExtension({ id, isPrivileged, withScriptingAPI = false }) {
+ let permissions = [];
+ let content_scripts = [];
+ let background = () => {
+ browser.test.sendMessage("background-ready");
+ };
+
+ if (isPrivileged) {
+ permissions.push("mozillaAddons");
+ }
+
+ if (withScriptingAPI) {
+ permissions.push("scripting");
+ // When we don't use a content script registered via the manifest, we
+ // should add the origin as a permission.
+ permissions.push("resource://foo/file_sample.html");
+
+ // Redefine background script to dynamically register the content script.
+ if (isPrivileged) {
+ background = async () => {
+ await browser.scripting.registerContentScripts([
+ {
+ id: "content_script",
+ js: ["content_script.js"],
+ matches: ["resource://foo/file_sample.html"],
+ persistAcrossSessions: false,
+ runAt: "document_start",
+ },
+ ]);
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(1, scripts.length, "expected 1 script");
+
+ browser.test.sendMessage("background-ready");
+ };
+ } else {
+ background = async () => {
+ await browser.test.assertRejects(
+ browser.scripting.registerContentScripts([
+ {
+ id: "content_script",
+ js: ["content_script.js"],
+ matches: ["resource://foo/file_sample.html"],
+ persistAcrossSessions: false,
+ runAt: "document_start",
+ },
+ ]),
+ /Invalid url pattern: resource:/,
+ "got expected error"
+ );
+
+ browser.test.sendMessage("background-ready");
+ };
+ }
+ } else {
+ content_scripts.push({
+ js: ["content_script.js"],
+ matches: ["resource://foo/file_sample.html"],
+ run_at: "document_start",
+ });
+ }
+
+ return ExtensionTestUtils.loadExtension({
+ isPrivileged,
+
+ manifest: {
+ manifest_version: 2,
+ browser_specific_settings: { gecko: { id } },
+ content_scripts,
+ permissions,
+ },
+
+ background,
+
+ files: {
+ "content_script.js"() {
+ browser.test.assertEq(
+ "resource://foo/file_sample.html",
+ document.documentURI,
+ `Loaded content script into the correct document (extension: ${browser.runtime.id})`
+ );
+ browser.test.sendMessage(`content-script-${browser.runtime.id}`);
+ },
+ },
+ });
+}
+
+const verifyRestrictSchemes = async ({ withScriptingAPI }) => {
+ let resProto = Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler);
+ resProto.setSubstitutionWithFlags(
+ "foo",
+ Services.io.newFileURI(do_get_file("data")),
+ resProto.ALLOW_CONTENT_ACCESS
+ );
+
+ let unprivileged = makeExtension({
+ id: "unprivileged@tests.mozilla.org",
+ isPrivileged: false,
+ withScriptingAPI,
+ });
+ let privileged = makeExtension({
+ id: "privileged@tests.mozilla.org",
+ isPrivileged: true,
+ withScriptingAPI,
+ });
+
+ await unprivileged.startup();
+ await unprivileged.awaitMessage("background-ready");
+
+ await privileged.startup();
+ await privileged.awaitMessage("background-ready");
+
+ unprivileged.onMessage(
+ "content-script-unprivileged@tests.mozilla.org",
+ () => {
+ ok(
+ false,
+ "Unprivileged extension executed content script on resource URL"
+ );
+ }
+ );
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `resource://foo/file_sample.html`
+ );
+
+ await privileged.awaitMessage("content-script-privileged@tests.mozilla.org");
+
+ await contentPage.close();
+
+ await privileged.unload();
+ await unprivileged.unload();
+};
+
+// Bug 1780507: this only works with MV2 currently because MV3's optional
+// permission mechanism lacks `restrictSchemes` flags.
+add_task(async function test_contentscript_restrictSchemes_mv2() {
+ await verifyRestrictSchemes({ withScriptingAPI: false });
+});
+
+// Bug 1780507: this only works with MV2 currently because MV3's optional
+// permission mechanism lacks `restrictSchemes` flags.
+add_task(async function test_contentscript_restrictSchemes_scripting_mv2() {
+ await verifyRestrictSchemes({ withScriptingAPI: true });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js
new file mode 100644
index 0000000000..2f10f8f252
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js
@@ -0,0 +1,61 @@
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+// Test that document_start content scripts don't block script-created
+// parsers.
+add_task(async function test_contentscript_scriptCreated() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_document_write.html"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ match_about_blank: true,
+ all_frames: true,
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": function () {
+ if (window === top) {
+ addEventListener(
+ "message",
+ msg => {
+ browser.test.assertEq(
+ "ok",
+ msg.data,
+ "document.write() succeeded"
+ );
+ browser.test.sendMessage("content-script-done");
+ },
+ { once: true }
+ );
+ }
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_document_write.html`
+ );
+
+ await extension.awaitMessage("content-script-done");
+
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js
new file mode 100644
index 0000000000..9ec72e6455
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js
@@ -0,0 +1,101 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_contentscript_reload_and_unload() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["contentscript.js"],
+ },
+ ],
+ },
+
+ files: {
+ "contentscript.js"() {
+ browser.test.sendMessage("contentscript-run");
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let events = [];
+ {
+ const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+ );
+ let record = (type, extensionContext) => {
+ let eventType = type == "proxy-context-load" ? "load" : "unload";
+ let url = extensionContext.uri.spec;
+ let extensionId = extensionContext.extension.id;
+ events.push({ eventType, url, extensionId });
+ };
+
+ Management.on("proxy-context-load", record);
+ Management.on("proxy-context-unload", record);
+ registerCleanupFunction(() => {
+ Management.off("proxy-context-load", record);
+ Management.off("proxy-context-unload", record);
+ });
+ }
+
+ const tabUrl = "http://example.com/data/file_sample.html";
+ let contentPage = await ExtensionTestUtils.loadContentPage(tabUrl);
+
+ await extension.awaitMessage("contentscript-run");
+
+ let contextEvents = events.splice(0);
+ equal(
+ contextEvents.length,
+ 1,
+ "ExtensionContext state change after loading a content script"
+ );
+ equal(
+ contextEvents[0].eventType,
+ "load",
+ "Create ExtensionContext for content script"
+ );
+ equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page");
+
+ await contentPage.spawn([], () => {
+ this.content.location.reload();
+ });
+ await extension.awaitMessage("contentscript-run");
+
+ contextEvents = events.splice(0);
+ equal(
+ contextEvents.length,
+ 2,
+ "ExtensionContext state changes after reloading a content script"
+ );
+ equal(contextEvents[0].eventType, "unload", "Unload old ExtensionContext");
+ equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page");
+ equal(
+ contextEvents[1].eventType,
+ "load",
+ "Create new ExtensionContext for content script"
+ );
+ equal(contextEvents[1].url, tabUrl, "ExtensionContext URL = page");
+
+ await contentPage.close();
+
+ contextEvents = events.splice(0);
+ equal(
+ contextEvents.length,
+ 1,
+ "ExtensionContext state change after unloading a content script"
+ );
+ equal(
+ contextEvents[0].eventType,
+ "unload",
+ "Unload ExtensionContext after closing the tab with the content script"
+ );
+ equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
new file mode 100644
index 0000000000..115fa77cc5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
@@ -0,0 +1,1383 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/**
+ * Tests that various types of inline content elements initiate requests
+ * with the triggering pringipal of the caller that requested the load,
+ * and that the correct security policies are applied to the resulting
+ * loads.
+ */
+
+// Make sure media pre-loading is enabled on Android so that our <audio> and
+// <video> elements trigger the expected requests.
+Services.prefs.setIntPref("media.autoplay.default", Ci.nsIAutoplay.ALLOWED);
+Services.prefs.setIntPref("media.preload.default", 3);
+
+// Increase the length of the code samples included in CSP reports so that we
+// can correctly validate them.
+Services.prefs.setIntPref(
+ "security.csp.reporting.script-sample.max-length",
+ 4096
+);
+
+// Do not trunacate the blocked-uri in CSP reports for frame navigations.
+Services.prefs.setBoolPref(
+ "security.csp.truncate_blocked_uri_for_frame_navigations",
+ false
+);
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+const server = createHttpServer({
+ hosts: ["example.com", "csplog.example.net"],
+});
+
+server.registerDirectory("/data/", do_get_file("data"));
+
+var gContentSecurityPolicy = null;
+
+const BASE_URL = `http://example.com`;
+const CSP_REPORT_PATH = "/csp-report.sjs";
+
+/**
+ * Registers a static HTML document with the given content at the given
+ * path in our test HTTP server.
+ *
+ * @param {string} path
+ * @param {string} content
+ */
+function registerStaticPage(path, content) {
+ server.registerPathHandler(path, (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html");
+ if (gContentSecurityPolicy) {
+ response.setHeader("Content-Security-Policy", gContentSecurityPolicy);
+ }
+ response.write(content);
+ });
+}
+
+/**
+ * A set of tags which are automatically closed in HTML documents, and
+ * do not require an explicit closing tag.
+ */
+const AUTOCLOSE_TAGS = new Set(["img", "input", "link", "source"]);
+
+/**
+ * An object describing the elements to create for a specific test.
+ *
+ * @typedef {object} ElementTestCase
+ * @property {Array} element
+ * A recursive array, describing the element to create, in the
+ * following format:
+ *
+ * ["tagname", {attr: "attrValue"},
+ * ["child-tagname", {attr: "value"}],
+ * ...]
+ *
+ * For each test, a DOM tree will be created with this structure.
+ * A source attribute, with the name `test.srcAttr` and a value
+ * based on the values of `test.src` and `opts`, will be added to
+ * the first leaf node encountered.
+ * @property {string} src
+ * The relative URL to use as the source of the element. Each
+ * load of this URL will have a separate set of query parameters
+ * appended to it, based on the values in `opts`.
+ * @property {string} [srcAttr = "src"]
+ * The attribute in which to store the element's source URL.
+ * @property {boolean} [liveSrc = false]
+ * If true, changing the source attribute after the element has
+ * been inserted into the document is expected to trigger a new
+ * load, and that configuration will be tested.
+ */
+
+/**
+ * Options for this specific configuration of an element test.
+ *
+ * @typedef {object} ElementTestOptions
+ * @property {string} origin
+ * The origin with which the content is expected to load. This
+ * may be one of "page", "contentScript", or "extension". The actual load
+ * of the URL will be tested against the computed origin strings for
+ * those two contexts.
+ * @property {string} source
+ * An arbitrary string which uniquely identifies the source of
+ * the load. For instance, each of these should have separate
+ * origin strings:
+ *
+ * - An element present in the initial page HTML.
+ * - An element injected by a page script belonging to web
+ * content.
+ * - An element injected by an extension content script.
+ */
+
+/**
+ * Data describing a test element, which can be used to create a
+ * corresponding DOM tree.
+ *
+ * @typedef {object} ElementData
+ * @property {string} tagName
+ * The tag name for the element.
+ * @property {object} attrs
+ * A property containing key-value pairs for each of the
+ * attribute's elements.
+ * @property {Array<ElementData>} children
+ * A possibly empty array of element data for child elements.
+ */
+
+/**
+ * Returns data necessary to create test elements for the given test,
+ * with the given options.
+ *
+ * @param {ElementTestCase} test
+ * An object describing the elements to create for a specific
+ * test. This element will be created under various
+ * circumstances, as described by `opts`.
+ * @param {ElementTestOptions} opts
+ * Options for this specific configuration of the test.
+ * @returns {ElementData}
+ */
+function getElementData(test, opts) {
+ let baseURL = typeof BASE_URL !== "undefined" ? BASE_URL : location.href;
+
+ let { srcAttr, src } = test;
+
+ // Absolutify the URL, so it passes sanity checks that ignore
+ // triggering principals for relative URLs.
+ src = new URL(
+ src +
+ `?origin=${encodeURIComponent(opts.origin)}&source=${encodeURIComponent(
+ opts.source
+ )}`,
+ baseURL
+ ).href;
+
+ let haveSrc = false;
+ function rec(element) {
+ let [tagName, attrs, ...children] = element;
+
+ if (children.length) {
+ children = children.map(rec);
+ } else if (!haveSrc) {
+ attrs = Object.assign({ [srcAttr]: src }, attrs);
+ haveSrc = true;
+ }
+
+ return { tagName, attrs, children };
+ }
+ return rec(test.element);
+}
+
+/**
+ * The result type of the {@see createElement} function.
+ *
+ * @typedef {object} CreateElementResult
+ * @property {Element} elem
+ * The root element of the created DOM tree.
+ * @property {Element} srcElem
+ * The element in the tree to which the source attribute must be
+ * added.
+ * @property {string} src
+ * The value of the source element.
+ */
+
+/**
+ * Creates a DOM tree for a given test, in a given configuration, as
+ * understood by {@see getElementData}, but without the `test.srcAttr`
+ * attribute having been set. The caller must set the value of that
+ * attribute to the returned `src` value.
+ *
+ * There are many different ways most source values can be set
+ * (DOM attribute, DOM property, ...) and many different contexts
+ * (content script verses page script). Each test should be run with as
+ * many variants of these as possible.
+ *
+ * @param {ElementTestCase} test
+ * A test object, as passed to {@see getElementData}.
+ * @param {ElementTestOptions} opts
+ * An options object, as passed to {@see getElementData}.
+ * @returns {CreateElementResult}
+ */
+function createElement(test, opts) {
+ let srcElem;
+ let src;
+
+ function rec({ tagName, attrs, children }) {
+ let elem = document.createElement(tagName);
+
+ for (let [key, val] of Object.entries(attrs)) {
+ if (key === test.srcAttr) {
+ srcElem = elem;
+ src = val;
+ } else {
+ elem.setAttribute(key, val);
+ }
+ }
+ for (let child of children) {
+ elem.appendChild(rec(child));
+ }
+ return elem;
+ }
+ let elem = rec(getElementData(test, opts));
+
+ return { elem, srcElem, src };
+}
+
+/**
+ * Escapes any occurrences of &, ", < or > with XML entities.
+ *
+ * @param {string} str
+ * The string to escape.
+ * @returns {string} The escaped string.
+ */
+function escapeXML(str) {
+ let replacements = {
+ "&": "&amp;",
+ '"': "&quot;",
+ "'": "&apos;",
+ "<": "&lt;",
+ ">": "&gt;",
+ };
+ return String(str).replace(/[&"''<>]/g, m => replacements[m]);
+}
+
+/**
+ * A tagged template function which escapes any XML metacharacters in
+ * interpolated values.
+ *
+ * @param {Array<string>} strings
+ * An array of literal strings extracted from the templates.
+ * @param {Array} values
+ * An array of interpolated values extracted from the template.
+ * @returns {string}
+ * The result of the escaped values interpolated with the literal
+ * strings.
+ */
+function escaped(strings, ...values) {
+ let result = [];
+
+ for (let [i, string] of strings.entries()) {
+ result.push(string);
+ if (i < values.length) {
+ result.push(escapeXML(values[i]));
+ }
+ }
+
+ return result.join("");
+}
+
+/**
+ * Converts the given test data, as accepted by {@see getElementData},
+ * to an HTML representation.
+ *
+ * @param {ElementTestCase} test
+ * A test object, as passed to {@see getElementData}.
+ * @param {ElementTestOptions} opts
+ * An options object, as passed to {@see getElementData}.
+ * @returns {string}
+ */
+function toHTML(test, opts) {
+ function rec({ tagName, attrs, children }) {
+ let html = [`<${tagName}`];
+ for (let [key, val] of Object.entries(attrs)) {
+ html.push(escaped` ${key}="${val}"`);
+ }
+
+ html.push(">");
+ if (!AUTOCLOSE_TAGS.has(tagName)) {
+ for (let child of children) {
+ html.push(rec(child));
+ }
+
+ html.push(`</${tagName}>`);
+ }
+ return html.join("");
+ }
+ return rec(getElementData(test, opts));
+}
+
+/**
+ * Injects various permutations of inline CSS into a content page, from both
+ * extension content script and content page contexts, and sends a "css-sources"
+ * message to the test harness describing the injected content for verification.
+ */
+function testInlineCSS() {
+ let urls = [];
+ let sources = [];
+
+ /**
+ * Constructs the URL of an image to be loaded by the given origin, and
+ * returns a CSS url() expression for it.
+ *
+ * The `name` parameter is an arbitrary name which should describe how the URL
+ * is loaded. The `opts` object may contain arbitrary properties which
+ * describe the load. Currently, only `inline` is recognized, and indicates
+ * that the URL is being used in an inline stylesheet which may be blocked by
+ * CSP.
+ *
+ * The URL and its parameters are recorded, and sent to the parent process for
+ * verification.
+ *
+ * @param {string} origin
+ * @param {string} name
+ * @param {object} [opts]
+ * @returns {string}
+ */
+ let i = 0;
+ let url = (origin, name, opts = {}) => {
+ let source = `${origin}-${name}`;
+
+ let { href } = new URL(
+ `css-${i++}.png?origin=${encodeURIComponent(
+ origin
+ )}&source=${encodeURIComponent(source)}`,
+ location.href
+ );
+
+ urls.push(Object.assign({}, opts, { href, origin, source }));
+ return `url("${href}")`;
+ };
+
+ /**
+ * Registers the given inline CSS source as being loaded by the given origin,
+ * and returns that CSS text.
+ *
+ * @param {string} origin
+ * @param {string} css
+ * @returns {string}
+ */
+ let source = (origin, css) => {
+ sources.push({ origin, css });
+ return css;
+ };
+
+ /**
+ * Saves the given function to be run after a short delay, just before sending
+ * the list of loaded sources to the parent process.
+ */
+ let laters = [];
+ let later = fn => {
+ laters.push(fn);
+ };
+
+ // Note: When accessing an element through `wrappedJSObject`, the operations
+ // occur in the content page context, using the content subject principal.
+ // When accessing it through X-ray wrappers, they happen in the content script
+ // context, using its subject principal.
+
+ {
+ let li = document.createElement("li");
+ li.setAttribute(
+ "style",
+ source(
+ "contentScript",
+ `background: ${url("contentScript", "li.style-first")}`
+ )
+ );
+ li.style.wrappedJSObject.listStyleImage = url(
+ "page",
+ "li.style.listStyleImage-second"
+ );
+ document.body.appendChild(li);
+ }
+
+ {
+ let li = document.createElement("li");
+ li.wrappedJSObject.setAttribute(
+ "style",
+ source(
+ "page",
+ `background: ${url("page", "li.style-first", { inline: true })}`
+ )
+ );
+ li.style.listStyleImage = url(
+ "contentScript",
+ "li.style.listStyleImage-second"
+ );
+ document.body.appendChild(li);
+ }
+
+ {
+ let li = document.createElement("li");
+ document.body.appendChild(li);
+ li.setAttribute(
+ "style",
+ source(
+ "contentScript",
+ `background: ${url("contentScript", "li.style-first")}`
+ )
+ );
+ later(() =>
+ li.wrappedJSObject.setAttribute(
+ "style",
+ source(
+ "page",
+ `background: ${url("page", "li.style-second", { inline: true })}`
+ )
+ )
+ );
+ }
+
+ {
+ let li = document.createElement("li");
+ document.body.appendChild(li);
+ li.wrappedJSObject.setAttribute(
+ "style",
+ source(
+ "page",
+ `background: ${url("page", "li.style-first", { inline: true })}`
+ )
+ );
+ later(() =>
+ li.setAttribute(
+ "style",
+ source(
+ "contentScript",
+ `background: ${url("contentScript", "li.style-second")}`
+ )
+ )
+ );
+ }
+
+ {
+ let li = document.createElement("li");
+ document.body.appendChild(li);
+ li.style.cssText = source(
+ "contentScript",
+ `background: ${url("contentScript", "li.style.cssText-first")}`
+ );
+
+ // TODO: This inline style should be blocked, since our style-src does not
+ // include 'unsafe-eval', but that is currently unimplemented.
+ later(() => {
+ li.style.wrappedJSObject.cssText = `background: ${url(
+ "page",
+ "li.style.cssText-second"
+ )}`;
+ });
+ }
+
+ // Creates a new element, inserts it into the page, and returns its CSS selector.
+ let divNum = 0;
+ function getSelector() {
+ let div = document.createElement("div");
+ div.id = `generated-div-${divNum++}`;
+ document.body.appendChild(div);
+ return `#${div.id}`;
+ }
+
+ for (let prop of ["textContent", "innerHTML"]) {
+ // Test creating <style> element from the extension side and then replacing
+ // its contents from the content side.
+ {
+ let sel = getSelector();
+ let style = document.createElement("style");
+ style[prop] = source(
+ "extension",
+ `${sel} { background: ${url("extension", `style-${prop}-first`)}; }`
+ );
+ document.head.appendChild(style);
+
+ later(() => {
+ style.wrappedJSObject[prop] = source(
+ "page",
+ `${sel} { background: ${url("page", `style-${prop}-second`, {
+ inline: true,
+ })}; }`
+ );
+ });
+ }
+
+ // Test creating <style> element from the extension side and then appending
+ // a text node to it. Regardless of whether the append happens from the
+ // content or extension side, this should cause the principal to be
+ // forgotten.
+ let testModifyAfterInject = (name, modifyFunc) => {
+ let sel = getSelector();
+ let style = document.createElement("style");
+ style[prop] = source(
+ "extension",
+ `${sel} { background: ${url(
+ "extension",
+ `style-${name}-${prop}-first`
+ )}; }`
+ );
+ document.head.appendChild(style);
+
+ later(() => {
+ modifyFunc(
+ style,
+ `${sel} { background: ${url("page", `style-${name}-${prop}-second`, {
+ inline: true,
+ })}; }`
+ );
+ source("page", style.textContent);
+ });
+ };
+
+ testModifyAfterInject("appendChild", (style, css) => {
+ style.appendChild(document.createTextNode(css));
+ });
+
+ // Test creating <style> element from the extension side and then appending
+ // to it using insertAdjacentHTML, with the same rules as above.
+ testModifyAfterInject("insertAdjacentHTML", (style, css) => {
+ // eslint-disable-next-line no-unsanitized/method
+ style.insertAdjacentHTML("beforeend", css);
+ });
+
+ // And again using insertAdjacentText.
+ testModifyAfterInject("insertAdjacentText", (style, css) => {
+ style.insertAdjacentText("beforeend", css);
+ });
+
+ // Test creating a style element and then accessing its CSSStyleSheet object.
+ {
+ let sel = getSelector();
+ let style = document.createElement("style");
+ style[prop] = source(
+ "extension",
+ `${sel} { background: ${url("extension", `style-${prop}-sheet`)}; }`
+ );
+ document.head.appendChild(style);
+
+ browser.test.assertThrows(
+ () => style.sheet.wrappedJSObject.cssRules,
+ /Not allowed to access cross-origin stylesheet/,
+ "Page content should not be able to access extension-generated CSS rules"
+ );
+
+ style.sheet.insertRule(
+ source(
+ "extension",
+ `${sel} { border-image: ${url(
+ "extension",
+ `style-${prop}-sheet-insertRule`
+ )}; }`
+ )
+ );
+ }
+ }
+
+ setTimeout(() => {
+ for (let fn of laters) {
+ fn();
+ }
+ browser.test.sendMessage("css-sources", { urls, sources });
+ });
+}
+
+/**
+ * A function which will be stringified, and run both as a page script
+ * and an extension content script, to test element injection under
+ * various configurations.
+ *
+ * @param {Array<ElementTestCase>} tests
+ * A list of test objects, as understood by {@see getElementData}.
+ * @param {ElementTestOptions} baseOpts
+ * A base options object, as understood by {@see getElementData},
+ * which represents the default values for injections under this
+ * context.
+ */
+function injectElements(tests, baseOpts) {
+ window.addEventListener(
+ "load",
+ () => {
+ if (typeof browser === "object") {
+ try {
+ testInlineCSS();
+ } catch (e) {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ }
+ }
+
+ // Basic smoke test to check that SVG images do not try to create a document
+ // with an expanded principal, which would cause a crash.
+ let img = document.createElement("img");
+ img.src = "data:image/svg+xml,%3Csvg%2F%3E";
+ document.body.appendChild(img);
+
+ let rand = Math.random();
+
+ // Basic smoke test to check that we don't try to create stylesheets with an
+ // expanded principal, which would cause a crash when loading font sets.
+ let cssText = `
+ @font-face {
+ font-family: "DoesNotExist${rand}";
+ src: url("fonts/DoesNotExist.${rand}.woff") format("woff");
+ font-weight: normal;
+ font-style: normal;
+ }`;
+
+ let link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = "data:text/css;base64," + btoa(cssText);
+ document.head.appendChild(link);
+
+ let style = document.createElement("style");
+ style.textContent = cssText;
+ document.head.appendChild(style);
+
+ let overrideOpts = opts => Object.assign({}, baseOpts, opts);
+ let opts = baseOpts;
+
+ // Build the full element with setAttr, then inject.
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ srcElem.setAttribute(test.srcAttr, src);
+ document.body.appendChild(elem);
+ }
+
+ // Build the full element with a property setter.
+ opts = overrideOpts({ source: `${baseOpts.source}-prop` });
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ srcElem[test.srcAttr] = src;
+ document.body.appendChild(elem);
+ }
+
+ // Build the element without the source attribute, inject, then set
+ // it.
+ opts = overrideOpts({ source: `${baseOpts.source}-attr-after-inject` });
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ document.body.appendChild(elem);
+ srcElem.setAttribute(test.srcAttr, src);
+ }
+
+ // Build the element without the source attribute, inject, then set
+ // the corresponding property.
+ opts = overrideOpts({ source: `${baseOpts.source}-prop-after-inject` });
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ document.body.appendChild(elem);
+ srcElem[test.srcAttr] = src;
+ }
+
+ // Build the element with a relative, rather than absolute, URL, and
+ // make sure it always has the page origin.
+ opts = overrideOpts({
+ source: `${baseOpts.source}-relative-url`,
+ origin: "page",
+ });
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ // Note: This assumes that the content page and the src URL are
+ // always at the server root. If that changes, the test will
+ // timeout waiting for matching requests.
+ src = src.replace(/.*\//, "");
+ srcElem.setAttribute(test.srcAttr, src);
+ document.body.appendChild(elem);
+ }
+
+ // If we're in an extension content script, do some additional checks.
+ if (typeof browser !== "undefined") {
+ // Build the element without the source attribute, inject, then
+ // have content set it.
+ opts = overrideOpts({
+ source: `${baseOpts.source}-content-attr-after-inject`,
+ origin: "page",
+ });
+
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+
+ document.body.appendChild(elem);
+ window.wrappedJSObject.elem = srcElem;
+ window.wrappedJSObject.eval(
+ `elem.setAttribute(${JSON.stringify(
+ test.srcAttr
+ )}, ${JSON.stringify(src)})`
+ );
+ }
+
+ // Build the full element, then let content inject.
+ opts = overrideOpts({
+ source: `${baseOpts.source}-content-inject-after-attr`,
+ });
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ srcElem.setAttribute(test.srcAttr, src);
+ window.wrappedJSObject.elem = elem;
+ window.wrappedJSObject.eval(`document.body.appendChild(elem)`);
+ }
+
+ // Build the element without the source attribute, let content set
+ // it, then inject.
+ opts = overrideOpts({
+ source: `${baseOpts.source}-inject-after-content-attr`,
+ origin: "page",
+ });
+
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ window.wrappedJSObject.elem = srcElem;
+ window.wrappedJSObject.eval(
+ `elem.setAttribute(${JSON.stringify(
+ test.srcAttr
+ )}, ${JSON.stringify(src)})`
+ );
+ document.body.appendChild(elem);
+ }
+
+ // Build the element with a dummy source attribute, inject, then
+ // let content change it.
+ opts = overrideOpts({
+ source: `${baseOpts.source}-content-change-after-inject`,
+ origin: "page",
+ });
+
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ srcElem.setAttribute(test.srcAttr, "meh.txt");
+ document.body.appendChild(elem);
+ window.wrappedJSObject.elem = srcElem;
+ window.wrappedJSObject.eval(
+ `elem.setAttribute(${JSON.stringify(
+ test.srcAttr
+ )}, ${JSON.stringify(src)})`
+ );
+ }
+ }
+ },
+ { once: true }
+ );
+}
+
+/**
+ * Stringifies the {@see injectElements} function for use as a page or
+ * content script.
+ *
+ * @param {Array<ElementTestCase>} tests
+ * A list of test objects, as understood by {@see getElementData}.
+ * @param {ElementTestOptions} opts
+ * A base options object, as understood by {@see getElementData},
+ * which represents the default values for injections under this
+ * context.
+ * @returns {string}
+ */
+function getInjectionScript(tests, opts) {
+ return `
+ ${getElementData}
+ ${createElement}
+ ${testInlineCSS}
+ (${injectElements})(${JSON.stringify(tests)},
+ ${JSON.stringify(opts)});
+ `;
+}
+
+/**
+ * Extracts the "origin" query parameter from the given URL, and returns it,
+ * along with the URL sans origin parameter.
+ *
+ * @param {string} origURL
+ * @returns {object}
+ * An object with `origin` and `baseURL` properties, containing the value
+ * or the URL's "origin" query parameter and the URL with that parameter
+ * removed, respectively.
+ */
+function getOriginBase(origURL) {
+ let url = new URL(origURL);
+ let origin = url.searchParams.get("origin");
+ url.searchParams.delete("origin");
+
+ return { origin, baseURL: url.href };
+}
+
+/**
+ * An object containing sets of base URLs and CSS sources which are present in
+ * the test page, sorted based on how they should be treated by CSP.
+ *
+ * @typedef {object} RequestedURLs
+ * @property {Set<string>} expectedURLs
+ * A set of URLs which should be successfully requested by the content
+ * page.
+ * @property {Set<string>} forbiddenURLs
+ * A set of URLs which are present in the content page, but should never
+ * generate requests.
+ * @property {Set<string>} blockedURLs
+ * A set of URLs which are present in the content page, and should be
+ * blocked by CSP, and reported in a CSP report.
+ * @property {Set<string>} blockedSources
+ * A set of inline CSS sources which should be blocked by CSP, and
+ * reported in a CSP report.
+ */
+
+/**
+ * Computes a list of expected and forbidden base URLs for the given
+ * sets of tests and sources. The base URL is the complete request URL
+ * with the `origin` query parameter removed.
+ *
+ * @param {Array<ElementTestCase>} tests
+ * A list of tests, as understood by {@see getElementData}.
+ * @param {Object<string, object>} expectedSources
+ * A set of sources for which each of the above tests is expected
+ * to generate one request, if each of the properties in the
+ * value object matches the value of the same property in the
+ * test object.
+ * @param {Object<string, object>} [forbiddenSources = {}]
+ * A set of sources for which requests should never be sent. Any
+ * matching requests from these sources will cause the test to
+ * fail.
+ * @returns {RequestedURLs}
+ */
+function computeBaseURLs(tests, expectedSources, forbiddenSources = {}) {
+ let expectedURLs = new Set();
+ let forbiddenURLs = new Set();
+
+ function* iterSources(test, sources) {
+ for (let [source, attrs] of Object.entries(sources)) {
+ // if a source defines attributes (e.g. liveSrc in PAGE_SOURCES etc.) then all
+ // attributes in the source must be matched by the test (see const TEST).
+ if (Object.keys(attrs).every(attr => attrs[attr] === test[attr])) {
+ yield `${BASE_URL}/${test.src}?source=${source}`;
+ }
+ }
+ }
+
+ for (let test of tests) {
+ for (let urlPrefix of iterSources(test, expectedSources)) {
+ expectedURLs.add(urlPrefix);
+ }
+ for (let urlPrefix of iterSources(test, forbiddenSources)) {
+ forbiddenURLs.add(urlPrefix);
+ }
+ }
+
+ return { expectedURLs, forbiddenURLs, blockedURLs: forbiddenURLs };
+}
+
+/**
+ * @typedef InjectedUrl
+ * A URL present in styles injected by the content script.
+ * @type {object}
+ * @property {string} origin
+ * The origin of the URL, one of "page", "contentScript", or "extension".
+ * @param {string} href
+ * The URL string.
+ * @param {boolean} inline
+ * If true, the URL is present in an inline stylesheet, which may be
+ * blocked by CSP prior to parsing, depending on its origin.
+ */
+
+/**
+ * @typedef InjectedSource
+ * An inline CSS source injected by the content script.
+ * @type {object}
+ * @param {string} origin
+ * The origin of the CSS, one of "page", "contentScript", or "extension".
+ * @param {string} css
+ * The CSS source text.
+ */
+
+/**
+ * Generates a set of expected and forbidden URLs and sources based on the CSS
+ * injected by our content script.
+ *
+ * @param {object} message
+ * The "css-sources" message sent by the content script, containing lists
+ * of CSS sources injected into the page.
+ * @param {Array<InjectedUrl>} message.urls
+ * A list of URLs present in styles injected by the content script.
+ * @param {Array<InjectedSource>} message.sources
+ * A list of inline CSS sources injected by the content script.
+ * @param {boolean} [cspEnabled = false]
+ * If true, a strict CSP is enabled for this page, and inline page
+ * sources should be blocked. URLs present in these sources will not be
+ * expected to generate a CSP report, the inline sources themselves will.
+ * @param {boolean} [contentCspEnabled = false]
+ * @returns {RequestedURLs}
+ */
+function computeExpectedForbiddenURLs(
+ { urls, sources },
+ cspEnabled = false,
+ contentCspEnabled = false
+) {
+ let expectedURLs = new Set();
+ let forbiddenURLs = new Set();
+ let blockedURLs = new Set();
+ let blockedSources = new Set();
+
+ for (let { href, origin, inline } of urls) {
+ let { baseURL } = getOriginBase(href);
+ if (cspEnabled && origin === "page") {
+ if (inline) {
+ forbiddenURLs.add(baseURL);
+ } else {
+ blockedURLs.add(baseURL);
+ }
+ } else if (contentCspEnabled && origin === "contentScript") {
+ if (inline) {
+ forbiddenURLs.add(baseURL);
+ }
+ } else {
+ expectedURLs.add(baseURL);
+ }
+ }
+
+ if (cspEnabled) {
+ for (let { origin, css } of sources) {
+ if (origin === "page") {
+ blockedSources.add(css);
+ }
+ }
+ }
+
+ return { expectedURLs, forbiddenURLs, blockedURLs, blockedSources };
+}
+
+/**
+ * Awaits the content loads for each of the given expected base URLs,
+ * and checks that their origin strings are as expected. Triggers a test
+ * failure if any of the given forbidden URLs is requested.
+ *
+ * @param {Promise<object>} urlsPromise
+ * A promise which resolves to an object containing expected and
+ * forbidden URL sets, as returned by {@see computeBaseURLs}.
+ * @param {Object<string, string>} origins
+ * A mapping of origin parameters as they appear in URL query
+ * strings to the origin strings returned by corresponding
+ * principals. These values are used to test requests against
+ * their expected origins.
+ * @returns {Promise}
+ * A promise which resolves when all requests have been
+ * processed.
+ */
+function awaitLoads(urlsPromise, origins) {
+ return new Promise(resolve => {
+ let expectedURLs, forbiddenURLs;
+ let queuedChannels = [];
+
+ let observer;
+
+ function checkChannel(channel) {
+ let origURL = channel.URI.spec;
+ let { baseURL, origin } = getOriginBase(origURL);
+
+ if (forbiddenURLs.has(baseURL)) {
+ ok(false, `Got unexpected request for forbidden URL ${origURL}`);
+ }
+
+ if (expectedURLs.has(baseURL)) {
+ expectedURLs.delete(baseURL);
+
+ equal(
+ channel.loadInfo.triggeringPrincipal.origin,
+ origins[origin],
+ `Got expected origin for URL ${origURL}`
+ );
+
+ if (!expectedURLs.size) {
+ Services.obs.removeObserver(observer, "http-on-modify-request");
+ info("Got all expected requests");
+ resolve();
+ }
+ }
+ }
+
+ urlsPromise.then(urls => {
+ expectedURLs = new Set(urls.expectedURLs);
+ forbiddenURLs = new Set([...urls.forbiddenURLs, ...urls.blockedURLs]);
+
+ for (let channel of queuedChannels.splice(0)) {
+ checkChannel(channel.QueryInterface(Ci.nsIChannel));
+ }
+ });
+
+ observer = (channel, topic, data) => {
+ if (expectedURLs) {
+ checkChannel(channel.QueryInterface(Ci.nsIChannel));
+ } else {
+ queuedChannels.push(channel);
+ }
+ };
+ Services.obs.addObserver(observer, "http-on-modify-request");
+ });
+}
+
+function readUTF8InputStream(stream) {
+ let buffer = NetUtil.readInputStream(stream, stream.available());
+ return new TextDecoder().decode(buffer);
+}
+
+/**
+ * Awaits CSP reports for each of the given forbidden base URLs.
+ * Triggers a test failure if any of the given expected URLs triggers a
+ * report.
+ *
+ * @param {Promise<object>} urlsPromise
+ * A promise which resolves to an object containing expected and
+ * forbidden URL sets, as returned by {@see computeBaseURLs}.
+ * @returns {Promise}
+ * A promise which resolves when all requests have been
+ * processed.
+ */
+function awaitCSP(urlsPromise) {
+ return new Promise(resolve => {
+ let expectedURLs, blockedURLs, blockedSources;
+ let queuedRequests = [];
+
+ function checkRequest(request) {
+ let body = JSON.parse(readUTF8InputStream(request.bodyInputStream));
+ let report = body["csp-report"];
+
+ let origURL = report["blocked-uri"];
+ if (origURL !== "inline" && origURL !== "") {
+ let { baseURL } = getOriginBase(origURL);
+
+ if (expectedURLs.has(baseURL)) {
+ ok(false, `Got unexpected CSP report for allowed URL ${origURL}`);
+ }
+
+ if (blockedURLs.has(baseURL)) {
+ blockedURLs.delete(baseURL);
+
+ ok(true, `Got CSP report for forbidden URL ${origURL}`);
+ }
+ }
+
+ let source = report["script-sample"];
+ if (source) {
+ if (blockedSources.has(source)) {
+ blockedSources.delete(source);
+
+ ok(
+ true,
+ `Got CSP report for forbidden inline source ${JSON.stringify(
+ source
+ )}`
+ );
+ }
+ }
+
+ if (!blockedURLs.size && !blockedSources.size) {
+ ok(true, "Got all expected CSP reports");
+ resolve();
+ }
+ }
+
+ urlsPromise.then(urls => {
+ blockedURLs = new Set(urls.blockedURLs);
+ blockedSources = new Set(urls.blockedSources);
+ ({ expectedURLs } = urls);
+
+ for (let request of queuedRequests.splice(0)) {
+ checkRequest(request);
+ }
+ });
+
+ server.registerPathHandler(CSP_REPORT_PATH, (request, response) => {
+ response.setStatusLine(request.httpVersion, 204, "No Content");
+
+ if (expectedURLs) {
+ checkRequest(request);
+ } else {
+ queuedRequests.push(request);
+ }
+ });
+ });
+}
+
+/**
+ * A list of tests to run in each context, as understood by
+ * {@see getElementData}.
+ */
+const TESTS = [
+ {
+ element: ["audio", {}],
+ src: "audio.webm",
+ },
+ {
+ element: ["audio", {}, ["source", {}]],
+ src: "audio-source.webm",
+ },
+ // TODO: <frame> element, which requires a frameset document.
+ {
+ // the blocked-uri for frame-navigations is the pre-path URI. For the
+ // purpose of this test we do not strip the blocked-uri by setting the
+ // preference 'truncate_blocked_uri_for_frame_navigations'
+ element: ["iframe", {}],
+ src: "iframe.html",
+ },
+ {
+ element: ["img", {}],
+ src: "img.png",
+ },
+ {
+ element: ["img", {}],
+ src: "imgset.png",
+ srcAttr: "srcset",
+ },
+ {
+ element: ["input", { type: "image" }],
+ src: "input.png",
+ },
+ {
+ element: ["link", { rel: "stylesheet" }],
+ src: "link.css",
+ srcAttr: "href",
+ },
+ {
+ element: ["picture", {}, ["source", {}], ["img", {}]],
+ src: "picture.png",
+ srcAttr: "srcset",
+ },
+ {
+ element: ["script", {}],
+ src: "script.js",
+ liveSrc: false,
+ },
+ {
+ element: ["video", {}],
+ src: "video.webm",
+ },
+ {
+ element: ["video", {}, ["source", {}]],
+ src: "video-source.webm",
+ },
+];
+
+for (let test of TESTS) {
+ if (!test.srcAttr) {
+ test.srcAttr = "src";
+ }
+ if (!("liveSrc" in test)) {
+ test.liveSrc = true;
+ }
+}
+
+/**
+ * A set of sources for which each of the above tests is expected to
+ * generate one request, if each of the properties in the value object
+ * matches the value of the same property in the test object.
+ */
+// Sources which load with the page context.
+const PAGE_SOURCES = {
+ "contentScript-content-attr-after-inject": { liveSrc: true },
+ "contentScript-content-change-after-inject": { liveSrc: true },
+ "contentScript-inject-after-content-attr": {},
+ "contentScript-relative-url": {},
+ pageHTML: {},
+ pageScript: {},
+ "pageScript-attr-after-inject": {},
+ "pageScript-prop": {},
+ "pageScript-prop-after-inject": {},
+ "pageScript-relative-url": {},
+};
+// Sources which load with the extension context.
+const EXTENSION_SOURCES = {
+ contentScript: {},
+ "contentScript-attr-after-inject": { liveSrc: true },
+ "contentScript-content-inject-after-attr": {},
+ "contentScript-prop": {},
+ "contentScript-prop-after-inject": {},
+};
+// When our default content script CSP is applied, only
+// liveSrc: true are loading. IOW, the "script" test above
+// will fail.
+const EXTENSION_SOURCES_CONTENT_CSP = {
+ contentScript: { liveSrc: true },
+ "contentScript-attr-after-inject": { liveSrc: true },
+ "contentScript-content-inject-after-attr": { liveSrc: true },
+ "contentScript-prop": { liveSrc: true },
+ "contentScript-prop-after-inject": { liveSrc: true },
+};
+// All sources.
+const SOURCES = Object.assign({}, PAGE_SOURCES, EXTENSION_SOURCES);
+
+registerStaticPage(
+ "/page.html",
+ `<!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title></title>
+ <script nonce="deadbeef">
+ ${getInjectionScript(TESTS, { source: "pageScript", origin: "page" })}
+ </script>
+ </head>
+ <body>
+ ${TESTS.map(test =>
+ toHTML(test, { source: "pageHTML", origin: "page" })
+ ).join("\n ")}
+ </body>
+ </html>`
+);
+
+function catchViolation() {
+ // eslint-disable-next-line mozilla/balanced-listeners
+ document.addEventListener("securitypolicyviolation", e => {
+ browser.test.assertTrue(
+ e.documentURI !== "moz-extension",
+ `securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}`
+ );
+ });
+}
+
+const EXTENSION_DATA = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/page.html"],
+ run_at: "document_start",
+ js: ["violation.js", "content_script.js"],
+ },
+ ],
+ },
+
+ files: {
+ "violation.js": catchViolation,
+ "content_script.js": getInjectionScript(TESTS, {
+ source: "contentScript",
+ origin: "contentScript",
+ }),
+ },
+};
+
+const pageURL = `${BASE_URL}/page.html`;
+const pageURI = Services.io.newURI(pageURL);
+
+// Merges the sets of expected URL and source data returned by separate
+// computedExpectedForbiddenURLs and computedBaseURLs calls.
+function mergeSources(a, b) {
+ return {
+ expectedURLs: new Set([...a.expectedURLs, ...b.expectedURLs]),
+ forbiddenURLs: new Set([...a.forbiddenURLs, ...b.forbiddenURLs]),
+ blockedURLs: new Set([...a.blockedURLs, ...b.blockedURLs]),
+ blockedSources: a.blockedSources || b.blockedSources,
+ };
+}
+
+// Returns a set of origin strings for the given extension and content page, for
+// use in verifying request triggering principals.
+function getOrigins(extension) {
+ return {
+ page: Services.scriptSecurityManager.createContentPrincipal(pageURI, {})
+ .origin,
+ contentScript: Cu.getObjectPrincipal(
+ Cu.Sandbox([pageURL, extension.principal])
+ ).origin,
+ extension: extension.principal.origin,
+ };
+}
+
+/**
+ * Tests that various types of inline content elements initiate requests
+ * with the triggering pringipal of the caller that requested the load.
+ */
+add_task(async function test_contentscript_triggeringPrincipals() {
+ let extension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+ await extension.startup();
+
+ let urlsPromise = extension.awaitMessage("css-sources").then(msg => {
+ return mergeSources(
+ computeExpectedForbiddenURLs(msg),
+ computeBaseURLs(TESTS, SOURCES)
+ );
+ });
+
+ let origins = getOrigins(extension.extension);
+ let finished = awaitLoads(urlsPromise, origins);
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
+
+ await finished;
+
+ await extension.unload();
+ await contentPage.close();
+
+ clearCache();
+});
+
+/**
+ * Tests that the correct CSP is applied to loads of inline content
+ * depending on whether the load was initiated by an extension or the
+ * content page.
+ */
+add_task(async function test_contentscript_csp() {
+ // TODO bug 1408193: We currently don't get the full set of CSP reports when
+ // running in network scheduling chaos mode. It's not entirely clear why.
+ let chaosMode = parseInt(Services.env.get("MOZ_CHAOSMODE"), 16);
+ let checkCSPReports = !(chaosMode === 0 || chaosMode & 0x02);
+
+ gContentSecurityPolicy = `default-src 'none' 'report-sample'; script-src 'nonce-deadbeef' 'unsafe-eval' 'report-sample'; report-uri ${CSP_REPORT_PATH};`;
+
+ let extension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+ await extension.startup();
+
+ let urlsPromise = extension.awaitMessage("css-sources").then(msg => {
+ return mergeSources(
+ computeExpectedForbiddenURLs(msg, true),
+ computeBaseURLs(TESTS, EXTENSION_SOURCES, PAGE_SOURCES)
+ );
+ });
+
+ let origins = getOrigins(extension.extension);
+
+ let finished = Promise.all([
+ awaitLoads(urlsPromise, origins),
+ checkCSPReports && awaitCSP(urlsPromise),
+ ]);
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
+
+ await finished;
+
+ await extension.unload();
+ await contentPage.close();
+});
+
+/**
+ * Tests that the correct CSP is applied to loads of inline content
+ * depending on whether the load was initiated by an extension or the
+ * content page.
+ */
+add_task(async function test_extension_contentscript_csp() {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+ // TODO bug 1408193: We currently don't get the full set of CSP reports when
+ // running in network scheduling chaos mode. It's not entirely clear why.
+ let chaosMode = parseInt(Services.env.get("MOZ_CHAOSMODE"), 16);
+ let checkCSPReports = !(chaosMode === 0 || chaosMode & 0x02);
+
+ gContentSecurityPolicy = `default-src 'none' 'report-sample'; script-src 'nonce-deadbeef' 'unsafe-eval' 'report-sample'; report-uri ${CSP_REPORT_PATH};`;
+
+ let data = {
+ ...EXTENSION_DATA,
+ manifest: {
+ ...EXTENSION_DATA.manifest,
+ manifest_version: 3,
+ host_permissions: ["http://example.com/*"],
+ granted_host_permissions: true,
+ },
+ temporarilyInstalled: true,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(data);
+ await extension.startup();
+
+ let urlsPromise = extension.awaitMessage("css-sources").then(msg => {
+ return mergeSources(
+ computeExpectedForbiddenURLs(msg, true, true),
+ computeBaseURLs(TESTS, EXTENSION_SOURCES_CONTENT_CSP, PAGE_SOURCES)
+ );
+ });
+
+ let origins = getOrigins(extension.extension);
+
+ let finished = Promise.all([
+ awaitLoads(urlsPromise, origins),
+ checkCSPReports && awaitCSP(urlsPromise),
+ ]);
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
+
+ await finished;
+
+ await extension.unload();
+ await contentPage.close();
+ Services.prefs.clearUserPref("extensions.manifestV3.enabled");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js
new file mode 100644
index 0000000000..9191f7633e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function content_script_unregistered_during_loadContentScript() {
+ let content_scripts = [];
+
+ for (let i = 0; i < 10; i++) {
+ content_scripts.push({
+ matches: ["<all_urls>"],
+ js: ["dummy.js"],
+ run_at: "document_start",
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts,
+ },
+ files: {
+ "dummy.js": function () {
+ browser.test.sendMessage("content-script-executed");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+ info("Wait for all the content scripts to be executed");
+ await Promise.all(
+ content_scripts.map(() => extension.awaitMessage("content-script-executed"))
+ );
+
+ const promiseDone = contentPage.legacySpawn([extension.id], extensionId => {
+ const { ExtensionProcessScript } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionProcessScript.sys.mjs"
+ );
+
+ return new Promise(resolve => {
+ // This recreates a scenario similar to Bug 1593240 and ensures that the
+ // related fix doesn't regress. Replacing loadContentScript with a
+ // function that unregisters all the content scripts make us sure that
+ // mutating the policy contentScripts doesn't trigger a crash due to
+ // the invalidation of the contentScripts iterator being used by the
+ // caller (ExtensionPolicyService::CheckContentScripts).
+ const { loadContentScript } = ExtensionProcessScript;
+ ExtensionProcessScript.loadContentScript = async (...args) => {
+ const policy = WebExtensionPolicy.getByID(extensionId);
+ let initial = policy.contentScripts.length;
+ let i = initial;
+ while (i) {
+ policy.unregisterContentScript(policy.contentScripts[--i]);
+ }
+ Services.tm.dispatchToMainThread(() =>
+ resolve({
+ initial,
+ final: policy.contentScripts.length,
+ })
+ );
+ // Call the real loadContentScript method.
+ return loadContentScript(...args);
+ };
+ });
+ });
+
+ info("Reload the webpage");
+ await contentPage.loadURL(`${BASE_URL}/file_sample.html`);
+ info("Wait for all the content scripts to be executed again");
+ await Promise.all(
+ content_scripts.map(() => extension.awaitMessage("content-script-executed"))
+ );
+ info("No crash triggered as expected");
+
+ Assert.deepEqual(
+ await promiseDone,
+ { initial: content_scripts.length, final: 0 },
+ "All content scripts unregistered as expected"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js
new file mode 100644
index 0000000000..0682d30933
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("layout.xml.prettyprint", true);
+
+const BASE_XML = '<?xml version="1.0" encoding="UTF-8"?>';
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/test.xml", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/xml; charset=utf-8", false);
+ response.write(`${BASE_XML}\n<note></note>`);
+});
+
+// Make sure that XML pretty printer runs after content scripts
+// that runs at document_start (See Bug 1605657).
+add_task(async function content_script_on_xml_prettyprinted_document() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ js: ["start.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "start.js": async function () {
+ const el = document.createElement("ext-el");
+ document.documentElement.append(el);
+ if (document.readyState !== "complete") {
+ await new Promise(resolve => {
+ document.addEventListener("DOMContentLoaded", resolve, {
+ once: true,
+ });
+ });
+ }
+ browser.test.sendMessage("content-script-done");
+ },
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/test.xml"
+ );
+
+ info("Wait content script and xml document to be fully loaded");
+ await extension.awaitMessage("content-script-done");
+
+ info("Verify the xml file is still pretty printed");
+ const res = await contentPage.spawn([], () => {
+ const doc = this.content.document;
+ const shadowRoot = doc.documentElement.openOrClosedShadowRoot;
+ const prettyPrintLink =
+ shadowRoot &&
+ shadowRoot.querySelector("link[href*='XMLPrettyPrint.css']");
+ return {
+ hasShadowRoot: !!shadowRoot,
+ hasPrettyPrintLink: !!prettyPrintLink,
+ };
+ });
+
+ Assert.deepEqual(
+ res,
+ { hasShadowRoot: true, hasPrettyPrintLink: true },
+ "The XML file has the pretty print shadowRoot"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js
new file mode 100644
index 0000000000..8a58b2475c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js
@@ -0,0 +1,62 @@
+"use strict";
+
+const server = createHttpServer({
+ hosts: ["example.net", "example.org"],
+});
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_process_switch_cross_origin_frame() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.org/*/file_iframe.html"],
+ all_frames: true,
+ js: ["cs.js"],
+ },
+ ],
+ },
+
+ files: {
+ "cs.js"() {
+ browser.test.assertEq(
+ location.href,
+ "http://example.org/data/file_iframe.html",
+ "url is ok"
+ );
+
+ // frameId is the BrowsingContext ID in practice.
+ let frameId = browser.runtime.getFrameId(window);
+ browser.test.sendMessage("content-script-loaded", frameId);
+ },
+ },
+ });
+
+ await extension.startup();
+
+ const contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.net/data/file_with_xorigin_frame.html"
+ );
+
+ const browserProcessId =
+ contentPage.browser.browsingContext.currentWindowGlobal.domProcess.childID;
+
+ const scriptFrameId = await extension.awaitMessage("content-script-loaded");
+
+ const children = contentPage.browser.browsingContext.children.map(bc => ({
+ browsingContextId: bc.id,
+ processId: bc.currentWindowGlobal.domProcess.childID,
+ }));
+
+ Assert.equal(children.length, 1);
+ Assert.equal(scriptFrameId, children[0].browsingContextId);
+
+ if (contentPage.remoteSubframes) {
+ Assert.notEqual(browserProcessId, children[0].processId);
+ } else {
+ Assert.equal(browserProcessId, children[0].processId);
+ }
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js
new file mode 100644
index 0000000000..7b92d5c4b7
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js
@@ -0,0 +1,59 @@
+"use strict";
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function test_contentscript_xrays() {
+ async function contentScript() {
+ let unwrapped = window.wrappedJSObject;
+
+ browser.test.assertEq(
+ "undefined",
+ typeof test,
+ "Should not have named X-ray property access"
+ );
+ browser.test.assertEq(
+ undefined,
+ window.test,
+ "Should not have named X-ray property access"
+ );
+ browser.test.assertEq(
+ "object",
+ typeof unwrapped.test,
+ "Should always have non-X-ray named property access"
+ );
+
+ browser.test.notifyPass("contentScriptXrays");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.awaitFinish("contentScriptXrays");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js b/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js
new file mode 100644
index 0000000000..028f5b5638
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js
@@ -0,0 +1,201 @@
+"use strict";
+
+const global = this;
+
+var { BaseContext, EventManager, EventEmitter } = ExtensionCommon;
+
+class FakeExtension extends EventEmitter {
+ constructor(id) {
+ super();
+ this.id = id;
+ }
+}
+
+class StubContext extends BaseContext {
+ constructor() {
+ let fakeExtension = new FakeExtension("test@web.extension");
+ super("testEnv", fakeExtension);
+ this.sandbox = Cu.Sandbox(global);
+ }
+
+ logActivity(type, name, data) {
+ // no-op required by subclass
+ }
+
+ get cloneScope() {
+ return this.sandbox;
+ }
+
+ get principal() {
+ return Cu.getObjectPrincipal(this.sandbox);
+ }
+}
+
+add_task(async function test_post_unload_promises() {
+ let context = new StubContext();
+
+ let fail = result => {
+ ok(false, `Unexpected callback: ${result}`);
+ };
+
+ // Make sure promises resolve normally prior to unload.
+ let promises = [
+ context.wrapPromise(Promise.resolve()),
+ context.wrapPromise(Promise.reject({ message: "" })).catch(() => {}),
+ ];
+
+ await Promise.all(promises);
+
+ // Make sure promises that resolve after unload do not trigger
+ // resolution handlers.
+
+ context.wrapPromise(Promise.resolve("resolved")).then(fail);
+
+ context.wrapPromise(Promise.reject({ message: "rejected" })).then(fail, fail);
+
+ context.unload();
+
+ // The `setTimeout` ensures that we return to the event loop after
+ // promise resolution, which means we're guaranteed to return after
+ // any micro-tasks that get enqueued by the resolution handlers above.
+ await new Promise(resolve => setTimeout(resolve, 0));
+});
+
+add_task(async function test_post_unload_listeners() {
+ let context = new StubContext();
+
+ let fire;
+ let manager = new EventManager({
+ context,
+ name: "EventManager",
+ register: _fire => {
+ fire = () => {
+ _fire.async();
+ };
+ return () => {};
+ },
+ });
+
+ let fail = event => {
+ ok(false, `Unexpected event: ${event}`);
+ };
+
+ // Check that event listeners isn't called after it has been removed.
+ manager.addListener(fail);
+
+ let promise = new Promise(resolve => manager.addListener(resolve));
+
+ fire();
+
+ // The `fireSingleton` call ia dispatched asynchronously, so it won't
+ // have fired by this point. The `fail` listener that we remove now
+ // should not be called, even though the event has already been
+ // enqueued.
+ manager.removeListener(fail);
+
+ // Wait for the remaining listener to be called, which should always
+ // happen after the `fail` listener would normally be called.
+ await promise;
+
+ // Check that the event listener isn't called after the context has
+ // unloaded.
+ manager.addListener(fail);
+
+ // The `fire` callback always dispatches events
+ // asynchronously, so we need to test that any pending event callbacks
+ // aren't fired after the context unloads. We also need to test that
+ // any `fire` calls that happen *after* the context is unloaded also
+ // do not trigger callbacks.
+ fire();
+ Promise.resolve().then(fire);
+
+ context.unload();
+
+ // The `setTimeout` ensures that we return to the event loop after
+ // promise resolution, which means we're guaranteed to return after
+ // any micro-tasks that get enqueued by the resolution handlers above.
+ await new Promise(resolve => setTimeout(resolve, 0));
+});
+
+class Context extends BaseContext {
+ constructor(principal) {
+ let fakeExtension = new FakeExtension("test@web.extension");
+ super("testEnv", fakeExtension);
+ Object.defineProperty(this, "principal", {
+ value: principal,
+ configurable: true,
+ });
+ this.sandbox = Cu.Sandbox(principal, { wantXrays: false });
+ }
+
+ logActivity(type, name, data) {
+ // no-op required by subclass
+ }
+
+ get cloneScope() {
+ return this.sandbox;
+ }
+}
+
+let ssm = Services.scriptSecurityManager;
+const PRINCIPAL1 = ssm.createContentPrincipalFromOrigin(
+ "http://www.example.org"
+);
+const PRINCIPAL2 = ssm.createContentPrincipalFromOrigin(
+ "http://www.somethingelse.org"
+);
+
+// Test that toJSON() works in the json sandbox
+add_task(async function test_stringify_toJSON() {
+ let context = new Context(PRINCIPAL1);
+ let obj = Cu.evalInSandbox(
+ "({hidden: true, toJSON() { return {visible: true}; } })",
+ context.sandbox
+ );
+
+ let stringified = context.jsonStringify(obj);
+ let expected = JSON.stringify({ visible: true });
+ equal(
+ stringified,
+ expected,
+ "Stringified object with toJSON() method is as expected"
+ );
+});
+
+// Test that stringifying in inaccessible property throws
+add_task(async function test_stringify_inaccessible() {
+ let context = new Context(PRINCIPAL1);
+ let sandbox = context.sandbox;
+ let sandbox2 = Cu.Sandbox(PRINCIPAL2);
+
+ Cu.waiveXrays(sandbox).subobj = Cu.evalInSandbox(
+ "({ subobject: true })",
+ sandbox2
+ );
+ let obj = Cu.evalInSandbox("({ local: true, nested: subobj })", sandbox);
+ Assert.throws(() => {
+ context.jsonStringify(obj);
+ }, /Permission denied to access property "toJSON"/);
+});
+
+add_task(async function test_stringify_accessible() {
+ // Test that an accessible property from another global is included
+ let principal = Cu.getObjectPrincipal(Cu.Sandbox([PRINCIPAL1, PRINCIPAL2]));
+ let context = new Context(principal);
+ let sandbox = context.sandbox;
+ let sandbox2 = Cu.Sandbox(PRINCIPAL2);
+
+ Cu.waiveXrays(sandbox).subobj = Cu.evalInSandbox(
+ "({ subobject: true })",
+ sandbox2
+ );
+ let obj = Cu.evalInSandbox("({ local: true, nested: subobj })", sandbox);
+ let stringified = context.jsonStringify(obj);
+
+ let expected = JSON.stringify({ local: true, nested: { subobject: true } });
+ equal(
+ stringified,
+ expected,
+ "Stringified object with accessible property is as expected"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js b/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js
new file mode 100644
index 0000000000..a828584ced
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js
@@ -0,0 +1,277 @@
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+// Each of these tests do the following:
+// 1. Load document to create an extension context (instance of BaseContext).
+// 2. Get weak reference to that context.
+// 3. Unload the document.
+// 4. Force GC and check that the weak reference has been invalidated.
+
+async function reloadTopContext(contentPage) {
+ await contentPage.legacySpawn(null, async () => {
+ let { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+ );
+ let windowNukeObserved = TestUtils.topicObserved("inner-window-nuked");
+ info(`Reloading top-level document`);
+ this.content.location.reload();
+ await windowNukeObserved;
+ info(`Reloaded top-level document`);
+ });
+}
+
+async function assertContextReleased(contentPage, description) {
+ await contentPage.legacySpawn(description, async assertionDescription => {
+ // Force GC, see https://searchfox.org/mozilla-central/rev/b0275bc977ad7fda615ef34b822bba938f2b16fd/testing/talos/talos/tests/devtools/addon/content/damp.js#84-98
+ // and https://searchfox.org/mozilla-central/rev/33c21c060b7f3a52477a73d06ebcb2bf313c4431/xpcom/base/nsMemoryReporterManager.cpp#2574-2585,2591-2594
+ let gcCount = 0;
+ while (gcCount < 30 && this.contextWeakRef.get() !== null) {
+ ++gcCount;
+ // The JS engine will sometimes hold IC stubs for function
+ // environments alive across multiple CCs, which can keep
+ // closed-over JS objects alive. A shrinking GC will throw those
+ // stubs away, and therefore side-step the problem.
+ Cu.forceShrinkingGC();
+ Cu.forceCC();
+ Cu.forceGC();
+ await new Promise(resolve => this.content.setTimeout(resolve, 0));
+ }
+
+ // The above loop needs to be repeated at most 3 times according to MinimizeMemoryUsage:
+ // https://searchfox.org/mozilla-central/rev/6f86cc3479f80ace97f62634e2c82a483d1ede40/xpcom/base/nsMemoryReporterManager.cpp#2644-2647
+ Assert.lessOrEqual(
+ gcCount,
+ 3,
+ `Context should have been GCd within a few GC attempts.`
+ );
+
+ // Each test will set this.contextWeakRef before unloading the document.
+ Assert.ok(!this.contextWeakRef.get(), assertionDescription);
+ });
+}
+
+add_task(async function test_ContentScriptContextChild_in_child_frame() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_iframe.html"],
+ js: ["content_script.js"],
+ all_frames: true,
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": "browser.test.sendMessage('contentScriptLoaded');",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_toplevel.html`
+ );
+ await extension.awaitMessage("contentScriptLoaded");
+
+ await contentPage.legacySpawn(extension.id, async extensionId => {
+ const { ExtensionContent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionContent.sys.mjs"
+ );
+ let frame = this.content.document.querySelector(
+ "iframe[src*='file_iframe.html']"
+ );
+ let context = ExtensionContent.getContextByExtensionId(
+ extensionId,
+ frame.contentWindow
+ );
+
+ Assert.ok(!!context, "Got content script context");
+
+ this.contextWeakRef = Cu.getWeakReference(context);
+ frame.remove();
+ });
+
+ await assertContextReleased(
+ contentPage,
+ "ContentScriptContextChild should have been released"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_ContentScriptContextChild_in_toplevel() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ all_frames: true,
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": "browser.test.sendMessage('contentScriptLoaded');",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+ await extension.awaitMessage("contentScriptLoaded");
+
+ await contentPage.legacySpawn(extension.id, async extensionId => {
+ const { ExtensionContent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionContent.sys.mjs"
+ );
+ let context = ExtensionContent.getContextByExtensionId(
+ extensionId,
+ this.content
+ );
+
+ Assert.ok(!!context, "Got content script context");
+
+ this.contextWeakRef = Cu.getWeakReference(context);
+ });
+
+ await reloadTopContext(contentPage);
+ await extension.awaitMessage("contentScriptLoaded");
+ await assertContextReleased(
+ contentPage,
+ "ContentScriptContextChild should have been released"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_ExtensionPageContextChild_in_child_frame() {
+ let extensionData = {
+ files: {
+ "iframe.html": `
+ <!DOCTYPE html><meta charset="utf8">
+ <script src="script.js"></script>
+ `,
+ "toplevel.html": `
+ <!DOCTYPE html><meta charset="utf8">
+ <iframe src="iframe.html"></iframe>
+ `,
+ "script.js": "browser.test.sendMessage('extensionPageLoaded');",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/toplevel.html`,
+ {
+ extension,
+ remote: extension.extension.remote,
+ }
+ );
+ await extension.awaitMessage("extensionPageLoaded");
+
+ await contentPage.legacySpawn(extension.id, async extensionId => {
+ let { ExtensionPageChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPageChild.sys.mjs"
+ );
+
+ let frame = this.content.document.querySelector(
+ "iframe[src*='iframe.html']"
+ );
+ let innerWindowID =
+ frame.browsingContext.currentWindowContext.innerWindowId;
+ let context = ExtensionPageChild.extensionContexts.get(innerWindowID);
+
+ Assert.ok(!!context, "Got extension page context for child frame");
+
+ this.contextWeakRef = Cu.getWeakReference(context);
+ frame.remove();
+ });
+
+ await assertContextReleased(
+ contentPage,
+ "ExtensionPageContextChild should have been released"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_ExtensionPageContextChild_in_toplevel() {
+ let extensionData = {
+ files: {
+ "toplevel.html": `
+ <!DOCTYPE html><meta charset="utf8">
+ <script src="script.js"></script>
+ `,
+ "script.js": "browser.test.sendMessage('extensionPageLoaded');",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/toplevel.html`,
+ {
+ extension,
+ remote: extension.extension.remote,
+ }
+ );
+ await extension.awaitMessage("extensionPageLoaded");
+
+ await contentPage.legacySpawn(extension.id, async extensionId => {
+ let { ExtensionPageChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPageChild.sys.mjs"
+ );
+
+ let innerWindowID = this.content.windowGlobalChild.innerWindowId;
+ let context = ExtensionPageChild.extensionContexts.get(innerWindowID);
+
+ Assert.ok(!!context, "Got extension page context for top-level document");
+
+ this.contextWeakRef = Cu.getWeakReference(context);
+ });
+
+ await reloadTopContext(contentPage);
+ await extension.awaitMessage("extensionPageLoaded");
+ // For some unknown reason, the context cannot forcidbly be released by the
+ // garbage collector unless we wait for a short while.
+ await contentPage.spawn([], async () => {
+ let start = Date.now();
+ // The treshold was found after running this subtest only, 300 times
+ // in a release build (100 of xpcshell, xpcshell-e10s and xpcshell-remote).
+ // With treshold 8, almost half of the tests complete after a 17-18 ms delay.
+ // With treshold 7, over half of the tests complete after a 13-14 ms delay,
+ // with 12 failures in 300 tests runs.
+ // Let's double that number to have a safety margin.
+ for (let i = 0; i < 15; ++i) {
+ await new Promise(resolve => this.content.setTimeout(resolve, 0));
+ }
+ info(`Going to GC after waiting for ${Date.now() - start} ms.`);
+ });
+ await assertContextReleased(
+ contentPage,
+ "ExtensionPageContextChild should have been released"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js
new file mode 100644
index 0000000000..29f8b030ff
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js
@@ -0,0 +1,588 @@
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+ ExtensionPreferencesManager:
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs",
+});
+
+const CONTAINERS_PREF = "privacy.userContext.enabled";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+add_task(async function startup() {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_contextualIdentities_without_permissions() {
+ function background() {
+ browser.test.assertTrue(
+ !browser.contextualIdentities,
+ "contextualIdentities API is not available when the contextualIdentities permission is not required"
+ );
+ browser.test.notifyPass("contextualIdentities_without_permission");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ background,
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "testing@thing.com" },
+ },
+ permissions: [],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("contextualIdentities_without_permission");
+ await extension.unload();
+});
+
+add_task(async function test_contextualIdentity_events() {
+ async function background() {
+ function createOneTimeListener(type) {
+ return new Promise((resolve, reject) => {
+ try {
+ browser.test.assertTrue(
+ type in browser.contextualIdentities,
+ `Found API object browser.contextualIdentities.${type}`
+ );
+ const listener = change => {
+ browser.test.assertTrue(
+ "contextualIdentity" in change,
+ `Found identity in change`
+ );
+ browser.contextualIdentities[type].removeListener(listener);
+ resolve(change);
+ };
+ browser.contextualIdentities[type].addListener(listener);
+ } catch (e) {
+ reject(e);
+ }
+ });
+ }
+
+ function assertExpected(expected, container) {
+ // Number of keys that are added by the APIs
+ const createdCount = 2;
+ for (let key of Object.keys(container)) {
+ browser.test.assertTrue(key in expected, `found property ${key}`);
+ browser.test.assertEq(
+ expected[key],
+ container[key],
+ `property value for ${key} is correct`
+ );
+ }
+ const hexMatch = /^#[0-9a-f]{6}$/;
+ browser.test.assertTrue(
+ hexMatch.test(expected.colorCode),
+ "Color code property was expected Hex shape"
+ );
+ const iconMatch = /^resource:\/\/usercontext-content\/[a-z]+[.]svg$/;
+ browser.test.assertTrue(
+ iconMatch.test(expected.iconUrl),
+ "Icon url property was expected shape"
+ );
+ browser.test.assertEq(
+ Object.keys(expected).length,
+ Object.keys(container).length + createdCount,
+ "all expected properties found"
+ );
+ }
+
+ let onCreatePromise = createOneTimeListener("onCreated");
+
+ let containerObj = { name: "foobar", color: "red", icon: "circle" };
+ let ci = await browser.contextualIdentities.create(containerObj);
+ browser.test.assertTrue(!!ci, "We have an identity");
+ const onCreateListenerResponse = await onCreatePromise;
+ const cookieStoreId = ci.cookieStoreId;
+ assertExpected(
+ onCreateListenerResponse.contextualIdentity,
+ Object.assign(containerObj, { cookieStoreId })
+ );
+
+ let onUpdatedPromise = createOneTimeListener("onUpdated");
+ let updateContainerObj = { name: "testing", color: "blue", icon: "dollar" };
+ ci = await browser.contextualIdentities.update(
+ cookieStoreId,
+ updateContainerObj
+ );
+ browser.test.assertTrue(!!ci, "We have an update identity");
+ const onUpdatedListenerResponse = await onUpdatedPromise;
+ assertExpected(
+ onUpdatedListenerResponse.contextualIdentity,
+ Object.assign(updateContainerObj, { cookieStoreId })
+ );
+
+ let onRemovePromise = createOneTimeListener("onRemoved");
+ ci = await browser.contextualIdentities.remove(
+ updateContainerObj.cookieStoreId
+ );
+ browser.test.assertTrue(!!ci, "We have an remove identity");
+ const onRemoveListenerResponse = await onRemovePromise;
+ assertExpected(
+ onRemoveListenerResponse.contextualIdentity,
+ Object.assign(updateContainerObj, { cookieStoreId })
+ );
+
+ browser.test.notifyPass("contextualIdentities_events");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "testing@thing.com" },
+ },
+ permissions: ["contextualIdentities"],
+ },
+ });
+
+ Services.prefs.setBoolPref(CONTAINERS_PREF, true);
+
+ await extension.startup();
+ await extension.awaitFinish("contextualIdentities_events");
+ await extension.unload();
+
+ Services.prefs.clearUserPref(CONTAINERS_PREF);
+});
+
+add_task(async function test_contextualIdentity_with_permissions() {
+ const initial = Services.prefs.getBoolPref(CONTAINERS_PREF);
+
+ async function background() {
+ let ci;
+ await browser.test.assertRejects(
+ browser.contextualIdentities.get("foobar"),
+ "Invalid contextual identity: foobar",
+ "API should reject here"
+ );
+ await browser.test.assertRejects(
+ browser.contextualIdentities.update("foobar", { name: "testing" }),
+ "Invalid contextual identity: foobar",
+ "API should reject for unknown updates"
+ );
+ await browser.test.assertRejects(
+ browser.contextualIdentities.remove("foobar"),
+ "Invalid contextual identity: foobar",
+ "API should reject for removing unknown containers"
+ );
+
+ ci = await browser.contextualIdentities.get("firefox-container-1");
+ browser.test.assertTrue(!!ci, "We have an identity");
+ browser.test.assertTrue("name" in ci, "We have an identity.name");
+ browser.test.assertTrue("color" in ci, "We have an identity.color");
+ browser.test.assertTrue("icon" in ci, "We have an identity.icon");
+ browser.test.assertEq("Personal", ci.name, "identity.name is correct");
+ browser.test.assertEq(
+ "firefox-container-1",
+ ci.cookieStoreId,
+ "identity.cookieStoreId is correct"
+ );
+
+ function listenForMessage(messageName, stateChangeBool) {
+ return new Promise(resolve => {
+ browser.test.onMessage.addListener(function listener(msg) {
+ browser.test.log(`Got message from background: ${msg}`);
+ if (msg === messageName + "-response") {
+ browser.test.onMessage.removeListener(listener);
+ resolve();
+ }
+ });
+ browser.test.log(
+ `Sending message to background: ${messageName} ${stateChangeBool}`
+ );
+ browser.test.sendMessage(messageName, stateChangeBool);
+ });
+ }
+
+ await listenForMessage("containers-state-change", false);
+
+ browser.test.assertRejects(
+ browser.contextualIdentities.query({}),
+ "Contextual identities are currently disabled",
+ "Throws when containers are disabled"
+ );
+
+ await listenForMessage("containers-state-change", true);
+
+ let cis = await browser.contextualIdentities.query({});
+ browser.test.assertEq(
+ 4,
+ cis.length,
+ "by default we should have 4 containers"
+ );
+
+ cis = await browser.contextualIdentities.query({ name: "Personal" });
+ browser.test.assertEq(
+ 1,
+ cis.length,
+ "by default we should have 1 container called Personal"
+ );
+
+ cis = await browser.contextualIdentities.query({ name: "foobar" });
+ browser.test.assertEq(
+ 0,
+ cis.length,
+ "by default we should have 0 container called foobar"
+ );
+
+ ci = await browser.contextualIdentities.create({
+ name: "foobar",
+ color: "red",
+ icon: "gift",
+ });
+ browser.test.assertTrue(!!ci, "We have an identity");
+ browser.test.assertEq("foobar", ci.name, "identity.name is correct");
+ browser.test.assertEq("red", ci.color, "identity.color is correct");
+ browser.test.assertEq("gift", ci.icon, "identity.icon is correct");
+ browser.test.assertTrue(
+ !!ci.cookieStoreId,
+ "identity.cookieStoreId is correct"
+ );
+
+ browser.test.assertRejects(
+ browser.contextualIdentities.create({
+ name: "foobar",
+ color: "red",
+ icon: "firefox",
+ }),
+ "Invalid icon firefox for container",
+ "Create container called with an invalid icon"
+ );
+
+ browser.test.assertRejects(
+ browser.contextualIdentities.create({
+ name: "foobar",
+ color: "firefox-orange",
+ icon: "gift",
+ }),
+ "Invalid color name firefox-orange for container",
+ "Create container called with an invalid color"
+ );
+
+ cis = await browser.contextualIdentities.query({});
+ browser.test.assertEq(
+ 5,
+ cis.length,
+ "we should still have have 5 containers"
+ );
+
+ ci = await browser.contextualIdentities.get(ci.cookieStoreId);
+ browser.test.assertTrue(!!ci, "We have an identity");
+ browser.test.assertEq("foobar", ci.name, "identity.name is correct");
+ browser.test.assertEq("red", ci.color, "identity.color is correct");
+ browser.test.assertEq("gift", ci.icon, "identity.icon is correct");
+
+ browser.test.assertRejects(
+ browser.contextualIdentities.update(ci.cookieStoreId, {
+ name: "foobar",
+ color: "red",
+ icon: "firefox",
+ }),
+ "Invalid icon firefox for container",
+ "Create container called with an invalid icon"
+ );
+
+ browser.test.assertRejects(
+ browser.contextualIdentities.update(ci.cookieStoreId, {
+ name: "foobar",
+ color: "firefox-orange",
+ icon: "gift",
+ }),
+ "Invalid color name firefox-orange for container",
+ "Create container called with an invalid color"
+ );
+
+ cis = await browser.contextualIdentities.query({});
+ browser.test.assertEq(5, cis.length, "now we have 5 identities");
+
+ ci = await browser.contextualIdentities.update(ci.cookieStoreId, {
+ name: "barfoo",
+ color: "blue",
+ icon: "cart",
+ });
+ browser.test.assertTrue(!!ci, "We have an identity");
+ browser.test.assertEq("barfoo", ci.name, "identity.name is correct");
+ browser.test.assertEq("blue", ci.color, "identity.color is correct");
+ browser.test.assertEq("cart", ci.icon, "identity.icon is correct");
+
+ ci = await browser.contextualIdentities.get(ci.cookieStoreId);
+ browser.test.assertTrue(!!ci, "We have an identity");
+ browser.test.assertEq("barfoo", ci.name, "identity.name is correct");
+ browser.test.assertEq("blue", ci.color, "identity.color is correct");
+ browser.test.assertEq("cart", ci.icon, "identity.icon is correct");
+
+ ci = await browser.contextualIdentities.remove(ci.cookieStoreId);
+ browser.test.assertTrue(!!ci, "We have an identity");
+ browser.test.assertEq("barfoo", ci.name, "identity.name is correct");
+ browser.test.assertEq("blue", ci.color, "identity.color is correct");
+ browser.test.assertEq("cart", ci.icon, "identity.icon is correct");
+
+ cis = await browser.contextualIdentities.query({});
+ browser.test.assertEq(4, cis.length, "we are back to 4 identities");
+
+ browser.test.notifyPass("contextualIdentities");
+ }
+
+ function makeExtension(id) {
+ return ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ background,
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id },
+ },
+ permissions: ["contextualIdentities"],
+ },
+ });
+ }
+
+ let extension = makeExtension("containers-test@mozilla.org");
+
+ extension.onMessage("containers-state-change", stateBool => {
+ Cu.reportError(`Got message "containers-state-change", ${stateBool}`);
+ Services.prefs.setBoolPref(CONTAINERS_PREF, stateBool);
+ Cu.reportError("Changed pref");
+ extension.sendMessage("containers-state-change-response");
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("contextualIdentities");
+ equal(
+ Services.prefs.getBoolPref(CONTAINERS_PREF),
+ true,
+ "Pref should now be enabled, whatever it's initial state"
+ );
+ await extension.unload();
+ equal(
+ Services.prefs.getBoolPref(CONTAINERS_PREF),
+ initial,
+ "Pref should now be initial state"
+ );
+
+ Services.prefs.clearUserPref(CONTAINERS_PREF);
+});
+
+add_task(async function test_contextualIdentity_extensions_enable_containers() {
+ const initial = Services.prefs.getBoolPref(CONTAINERS_PREF);
+ async function background() {
+ let ci = await browser.contextualIdentities.get("firefox-container-1");
+ browser.test.assertTrue(!!ci, "We have an identity");
+
+ browser.test.notifyPass("contextualIdentities");
+ }
+ function makeExtension(id) {
+ return ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ background,
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id },
+ },
+ permissions: ["contextualIdentities"],
+ },
+ });
+ }
+ async function testSetting(expect, message) {
+ let setting = await ExtensionPreferencesManager.getSetting(
+ "privacy.containers"
+ );
+ if (expect === null) {
+ equal(setting, null, message);
+ } else {
+ equal(setting.value, expect, message);
+ }
+ }
+ function testPref(expect, message) {
+ equal(Services.prefs.getBoolPref(CONTAINERS_PREF), expect, message);
+ }
+
+ let extension = makeExtension("containers-test@mozilla.org");
+ await extension.startup();
+ await extension.awaitFinish("contextualIdentities");
+ equal(
+ Services.prefs.getBoolPref(CONTAINERS_PREF),
+ true,
+ "Pref should now be enabled, whatever it's initial state"
+ );
+ await extension.unload();
+ await testSetting(null, "setting should be unset");
+ testPref(initial, "setting should be initial value");
+
+ // Lets set containers explicitly to be off and test we keep it that way after removal
+ Services.prefs.setBoolPref(CONTAINERS_PREF, false);
+
+ let extension1 = makeExtension("containers-test-1@mozilla.org");
+ await extension1.startup();
+ await extension1.awaitFinish("contextualIdentities");
+ await testSetting(extension1.id, "setting should be controlled");
+ testPref(true, "Pref should now be enabled, whatever it's initial state");
+
+ await extension1.unload();
+ await testSetting(null, "setting should be unset");
+ testPref(false, "Pref should be false");
+
+ // Lets set containers explicitly to be on and test we keep it that way after removal.
+ Services.prefs.setBoolPref(CONTAINERS_PREF, true);
+
+ let extension2 = makeExtension("containers-test-2@mozilla.org");
+ let extension3 = makeExtension("containers-test-3@mozilla.org");
+ await extension2.startup();
+ await extension2.awaitFinish("contextualIdentities");
+ await extension3.startup();
+ await extension3.awaitFinish("contextualIdentities");
+
+ // Flip the ordering to check it's still enabled
+ await testSetting(extension3.id, "setting should still be controlled by 3");
+ testPref(true, "Pref should now be enabled 1");
+ await extension3.unload();
+ await testSetting(extension2.id, "setting should still be controlled by 2");
+ testPref(true, "Pref should now be enabled 2");
+ await extension2.unload();
+ await testSetting(null, "setting should be unset");
+ testPref(true, "Pref should now be enabled 3");
+
+ Services.prefs.clearUserPref(CONTAINERS_PREF);
+});
+
+add_task(async function test_contextualIdentity_preference_change() {
+ async function background() {
+ let extensionInfo = await browser.management.getSelf();
+ if (extensionInfo.version == "1.0.0") {
+ const containers = await browser.contextualIdentities.query({});
+ browser.test.assertEq(
+ containers.length,
+ 4,
+ "We still have the original containers"
+ );
+ await browser.contextualIdentities.create({
+ name: "foobar",
+ color: "red",
+ icon: "circle",
+ });
+ }
+ const containers = await browser.contextualIdentities.query({});
+ browser.test.assertEq(containers.length, 5, "We have a new container");
+ if (extensionInfo.version == "1.1.0") {
+ await browser.contextualIdentities.remove(containers[4].cookieStoreId);
+ }
+ browser.test.notifyPass("contextualIdentities");
+ }
+ function makeExtension(id, version) {
+ return ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ background,
+ manifest: {
+ version,
+ browser_specific_settings: {
+ gecko: { id },
+ },
+ permissions: ["contextualIdentities"],
+ },
+ });
+ }
+
+ Services.prefs.setBoolPref(CONTAINERS_PREF, false);
+ let extension = makeExtension("containers-pref-test@mozilla.org", "1.0.0");
+ await extension.startup();
+ await extension.awaitFinish("contextualIdentities");
+ equal(
+ Services.prefs.getBoolPref(CONTAINERS_PREF),
+ true,
+ "Pref should now be enabled, whatever it's initial state"
+ );
+
+ let extension2 = makeExtension("containers-pref-test@mozilla.org", "1.1.0");
+ await extension2.startup();
+ await extension2.awaitFinish("contextualIdentities");
+
+ await extension.unload();
+ equal(
+ Services.prefs.getBoolPref(CONTAINERS_PREF),
+ false,
+ "Pref should now be the initial state we set it to."
+ );
+
+ Services.prefs.clearUserPref(CONTAINERS_PREF);
+});
+
+add_task(
+ { pref_set: [["extensions.eventPages.enabled", true]] },
+ async function test_contextualIdentity_event_page() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "eventpage@mochitest" },
+ },
+ permissions: ["contextualIdentities"],
+ background: { persistent: false },
+ },
+ background() {
+ browser.contextualIdentities.onCreated.addListener(() => {
+ browser.test.sendMessage("created");
+ });
+ browser.contextualIdentities.onUpdated.addListener(() => {});
+ browser.contextualIdentities.onRemoved.addListener(() => {
+ browser.test.sendMessage("removed");
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ const EVENTS = ["onCreated", "onUpdated", "onRemoved"];
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ for (let event of EVENTS) {
+ assertPersistentListeners(extension, "contextualIdentities", event, {
+ primed: false,
+ });
+ }
+
+ await extension.terminateBackground();
+ for (let event of EVENTS) {
+ assertPersistentListeners(extension, "contextualIdentities", event, {
+ primed: true,
+ });
+ }
+
+ // test events waken background
+ let identity = ContextualIdentityService.create("foobar", "circle", "red");
+
+ await extension.awaitMessage("ready");
+ await extension.awaitMessage("created");
+ for (let event of EVENTS) {
+ assertPersistentListeners(extension, "contextualIdentities", event, {
+ primed: false,
+ });
+ }
+
+ ContextualIdentityService.remove(identity.userContextId);
+ await extension.awaitMessage("removed");
+
+ // check primed listeners after startup
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+
+ for (let event of EVENTS) {
+ assertPersistentListeners(extension, "contextualIdentities", event, {
+ primed: true,
+ });
+ }
+
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js
new file mode 100644
index 0000000000..8fa0a7ed0b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js
@@ -0,0 +1,567 @@
+"use strict";
+
+const { UrlClassifierTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/UrlClassifierTestUtils.sys.mjs"
+);
+
+const {
+ // cookieBehavior constants.
+ BEHAVIOR_REJECT,
+ BEHAVIOR_REJECT_TRACKER,
+} = Ci.nsICookieService;
+
+function createPage({ script, body = "" } = {}) {
+ if (script) {
+ body += `<script src="${script}"></script>`;
+ }
+
+ return `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ ${body}
+ </body>
+ </html>`;
+}
+
+const server = createHttpServer({ hosts: ["example.com", "itisatracker.org"] });
+server.registerDirectory("/data/", do_get_file("data"));
+server.registerPathHandler("/test-cookies", (request, response) => {
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/json", false);
+ response.setHeader("Set-Cookie", "myKey=myCookie", true);
+ response.write('{"success": true}');
+});
+server.registerPathHandler("/subframe.html", (request, response) => {
+ response.write(createPage());
+});
+server.registerPathHandler("/page-with-tracker.html", (request, response) => {
+ response.write(
+ createPage({
+ body: `<iframe src="http://itisatracker.org/test-cookies"></iframe>`,
+ })
+ );
+});
+server.registerPathHandler("/sw.js", (request, response) => {
+ response.setHeader("Content-Type", "text/javascript", false);
+ response.write("");
+});
+
+function assertCookiesForHost(url, cookiesCount, message) {
+ const { host } = new URL(url);
+ const cookies = Services.cookies.cookies.filter(
+ cookie => cookie.host === host
+ );
+ equal(cookies.length, cookiesCount, message);
+ return cookies;
+}
+
+// Test that the indexedDB and localStorage are allowed in an extension page
+// and that the indexedDB is allowed in a extension worker.
+add_task(async function test_ext_page_allowed_storage() {
+ function testWebStorages() {
+ const url = window.location.href;
+
+ try {
+ // In a webpage accessing indexedDB throws on cookiesBehavior reject,
+ // here we verify that doesn't happen for an extension page.
+ browser.test.assertTrue(
+ indexedDB,
+ "IndexedDB global should be accessible"
+ );
+
+ // In a webpage localStorage is undefined on cookiesBehavior reject,
+ // here we verify that doesn't happen for an extension page.
+ browser.test.assertTrue(
+ localStorage,
+ "localStorage global should be defined"
+ );
+
+ const worker = new Worker("worker.js");
+ worker.onmessage = event => {
+ browser.test.assertTrue(
+ event.data.pass,
+ "extension page worker have access to indexedDB"
+ );
+
+ browser.test.sendMessage("test-storage:done", url);
+ };
+
+ worker.postMessage({});
+ } catch (err) {
+ browser.test.fail(`Unexpected error: ${err}`);
+ browser.test.sendMessage("test-storage:done", url);
+ }
+ }
+
+ function testWorker() {
+ this.onmessage = () => {
+ try {
+ void indexedDB;
+ postMessage({ pass: true });
+ } catch (err) {
+ postMessage({ pass: false });
+ throw err;
+ }
+ };
+ }
+
+ async function createExtension() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "test_web_storages.js": testWebStorages,
+ "worker.js": testWorker,
+ "page_subframe.html": createPage({ script: "test_web_storages.js" }),
+ "page_with_subframe.html": createPage({
+ body: '<iframe src="page_subframe.html"></iframe>',
+ }),
+ "page.html": createPage({
+ script: "test_web_storages.js",
+ }),
+ },
+ });
+
+ await extension.startup();
+
+ const EXT_BASE_URL = `moz-extension://${extension.uuid}/`;
+
+ return { extension, EXT_BASE_URL };
+ }
+
+ const cookieBehaviors = [
+ "BEHAVIOR_LIMIT_FOREIGN",
+ "BEHAVIOR_REJECT_FOREIGN",
+ "BEHAVIOR_REJECT",
+ "BEHAVIOR_REJECT_TRACKER",
+ "BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN",
+ ];
+ equal(
+ cookieBehaviors.length,
+ Ci.nsICookieService.BEHAVIOR_LAST,
+ "all behaviors should be covered"
+ );
+
+ for (const behavior of cookieBehaviors) {
+ info(
+ `Test extension page access to indexedDB & localStorage with ${behavior}`
+ );
+ ok(
+ behavior in Ci.nsICookieService,
+ `${behavior} is a valid CookieBehavior`
+ );
+ Services.prefs.setIntPref(
+ "network.cookie.cookieBehavior",
+ Ci.nsICookieService[behavior]
+ );
+
+ // Create a new extension to ensure that the cookieBehavior just set is going to be
+ // used for the requests triggered by the extension page.
+ const { extension, EXT_BASE_URL } = await createExtension();
+ const extPage = await ExtensionTestUtils.loadContentPage("about:blank", {
+ extension,
+ remote: extension.extension.remote,
+ });
+
+ info("Test from a top level extension page");
+ await extPage.loadURL(`${EXT_BASE_URL}page.html`);
+
+ let testedFromURL = await extension.awaitMessage("test-storage:done");
+ equal(
+ testedFromURL,
+ `${EXT_BASE_URL}page.html`,
+ "Got the results from the expected url"
+ );
+
+ info("Test from a sub frame extension page");
+ await extPage.loadURL(`${EXT_BASE_URL}page_with_subframe.html`);
+
+ testedFromURL = await extension.awaitMessage("test-storage:done");
+ equal(
+ testedFromURL,
+ `${EXT_BASE_URL}page_subframe.html`,
+ "Got the results from the expected url"
+ );
+
+ await extPage.close();
+ await extension.unload();
+ }
+});
+
+add_task(async function test_ext_page_3rdparty_cookies() {
+ // Disable tracking protection to test cookies on BEHAVIOR_REJECT_TRACKER
+ // (otherwise tracking protection would block the tracker iframe and
+ // we would not be actually checking the cookie behavior).
+ Services.prefs.setBoolPref("privacy.trackingprotection.enabled", false);
+ await UrlClassifierTestUtils.addTestTrackers();
+ registerCleanupFunction(function () {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ Services.prefs.clearUserPref("privacy.trackingprotection.enabled");
+ Services.cookies.removeAll();
+ });
+
+ function testRequestScript() {
+ browser.test.onMessage.addListener((msg, url) => {
+ const done = () => {
+ browser.test.sendMessage(`${msg}:done`);
+ };
+
+ switch (msg) {
+ case "xhr": {
+ let req = new XMLHttpRequest();
+ req.onload = done;
+ req.open("GET", url);
+ req.send();
+ break;
+ }
+ case "fetch": {
+ window.fetch(url).then(done);
+ break;
+ }
+ case "worker fetch": {
+ const worker = new Worker("test_worker.js");
+ worker.onmessage = evt => {
+ if (evt.data.requestDone) {
+ done();
+ }
+ };
+ worker.postMessage({ url });
+ break;
+ }
+ default: {
+ browser.test.fail(`Received an unexpected message: ${msg}`);
+ done();
+ }
+ }
+ });
+
+ browser.test.sendMessage("testRequestScript:ready", window.location.href);
+ }
+
+ function testWorker() {
+ this.onmessage = evt => {
+ fetch(evt.data.url).then(() => {
+ postMessage({ requestDone: true });
+ });
+ };
+ }
+
+ async function createExtension() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://example.com/*", "http://itisatracker.org/*"],
+ },
+ files: {
+ "test_worker.js": testWorker,
+ "test_request.js": testRequestScript,
+ "page_subframe.html": createPage({ script: "test_request.js" }),
+ "page_with_subframe.html": createPage({
+ body: '<iframe src="page_subframe.html"></iframe>',
+ }),
+ "page.html": createPage({ script: "test_request.js" }),
+ },
+ });
+
+ await extension.startup();
+
+ const EXT_BASE_URL = `moz-extension://${extension.uuid}`;
+
+ return { extension, EXT_BASE_URL };
+ }
+
+ const testUrl = "http://example.com/test-cookies";
+ const testRequests = ["xhr", "fetch", "worker fetch"];
+ const tests = [
+ { behavior: "BEHAVIOR_ACCEPT", cookiesCount: 1 },
+ { behavior: "BEHAVIOR_REJECT_FOREIGN", cookiesCount: 1 },
+ { behavior: "BEHAVIOR_REJECT", cookiesCount: 0 },
+ { behavior: "BEHAVIOR_LIMIT_FOREIGN", cookiesCount: 1 },
+ { behavior: "BEHAVIOR_REJECT_TRACKER", cookiesCount: 1 },
+ ];
+
+ function clearAllCookies() {
+ Services.cookies.removeAll();
+ let cookies = Services.cookies.cookies;
+ equal(cookies.length, 0, "There shouldn't be any cookies after clearing");
+ }
+
+ async function runTestRequests(extension, cookiesCount, msg) {
+ for (const testRequest of testRequests) {
+ clearAllCookies();
+ extension.sendMessage(testRequest, testUrl);
+ await extension.awaitMessage(`${testRequest}:done`);
+ assertCookiesForHost(
+ testUrl,
+ cookiesCount,
+ `${msg}: cookies count on ${testRequest} "${testUrl}"`
+ );
+ }
+ }
+
+ for (const { behavior, cookiesCount } of tests) {
+ info(`Test cookies on http requests with ${behavior}`);
+ ok(
+ behavior in Ci.nsICookieService,
+ `${behavior} is a valid CookieBehavior`
+ );
+ Services.prefs.setIntPref(
+ "network.cookie.cookieBehavior",
+ Ci.nsICookieService[behavior]
+ );
+
+ // Create a new extension to ensure that the cookieBehavior just set is going to be
+ // used for the requests triggered by the extension page.
+ const { extension, EXT_BASE_URL } = await createExtension();
+
+ // Run all the test requests on a top level extension page.
+ let extPage = await ExtensionTestUtils.loadContentPage(
+ `${EXT_BASE_URL}/page.html`,
+ {
+ extension,
+ remote: extension.extension.remote,
+ }
+ );
+ await extension.awaitMessage("testRequestScript:ready");
+ await runTestRequests(
+ extension,
+ cookiesCount,
+ `Test top level extension page on ${behavior}`
+ );
+ await extPage.close();
+
+ // Rerun all the test requests on a sub frame extension page.
+ extPage = await ExtensionTestUtils.loadContentPage(
+ `${EXT_BASE_URL}/page_with_subframe.html`,
+ {
+ extension,
+ remote: extension.extension.remote,
+ }
+ );
+ await extension.awaitMessage("testRequestScript:ready");
+ await runTestRequests(
+ extension,
+ cookiesCount,
+ `Test sub frame extension page on ${behavior}`
+ );
+ await extPage.close();
+
+ await extension.unload();
+ }
+
+ // Test tracking url blocking from a webpage subframe.
+ info(
+ "Testing blocked tracker cookies in webpage subframe on BEHAVIOR_REJECT_TRACKERS"
+ );
+ Services.prefs.setIntPref(
+ "network.cookie.cookieBehavior",
+ BEHAVIOR_REJECT_TRACKER
+ );
+
+ const trackerURL = "http://itisatracker.org/test-cookies";
+ const { extension, EXT_BASE_URL } = await createExtension();
+ const extPage = await ExtensionTestUtils.loadContentPage(
+ `${EXT_BASE_URL}/_generated_background_page.html`,
+ {
+ extension,
+ remote: extension.extension.remote,
+ }
+ );
+ clearAllCookies();
+
+ await extPage.spawn(
+ ["http://example.com/page-with-tracker.html"],
+ async iframeURL => {
+ const iframe = this.content.document.createElement("iframe");
+ iframe.setAttribute("src", iframeURL);
+ return new Promise(resolve => {
+ iframe.onload = () => resolve();
+ this.content.document.body.appendChild(iframe);
+ });
+ }
+ );
+
+ assertCookiesForHost(
+ trackerURL,
+ 0,
+ "Test cookies on web subframe inside top level extension page on BEHAVIOR_REJECT_TRACKER"
+ );
+ clearAllCookies();
+
+ await extPage.close();
+ await extension.unload();
+});
+
+// Test that a webpage embedded as a subframe of an extension page is not allowed to use
+// IndexedDB and register a ServiceWorker when it shouldn't be based on the cookieBehavior.
+add_task(
+ async function test_webpage_subframe_storage_respect_cookiesBehavior() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://example.com/*"],
+ web_accessible_resources: ["subframe.html"],
+ },
+ files: {
+ "toplevel.html": createPage({
+ body: `
+ <iframe id="ext" src="subframe.html"></iframe>
+ <iframe id="web" src="http://example.com/subframe.html"></iframe>
+ `,
+ }),
+ "subframe.html": createPage(),
+ },
+ });
+
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", BEHAVIOR_REJECT);
+
+ await extension.startup();
+
+ let extensionPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/toplevel.html`,
+ {
+ extension,
+ remote: extension.extension.remote,
+ }
+ );
+
+ let results = await extensionPage.spawn([], async () => {
+ let extFrame = this.content.document.querySelector("iframe#ext");
+ let webFrame = this.content.document.querySelector("iframe#web");
+
+ function testIDB(win) {
+ try {
+ void win.indexedDB;
+ return { success: true };
+ } catch (err) {
+ return { error: `${err}` };
+ }
+ }
+
+ async function testServiceWorker(win) {
+ try {
+ await win.navigator.serviceWorker.register("sw.js");
+ return { success: true };
+ } catch (err) {
+ return { error: `${err}` };
+ }
+ }
+
+ return {
+ extTopLevel: testIDB(this.content),
+ extSubFrame: testIDB(extFrame.contentWindow),
+ webSubFrame: testIDB(webFrame.contentWindow),
+ webServiceWorker: await testServiceWorker(webFrame.contentWindow),
+ };
+ });
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/subframe.html"
+ );
+
+ results.extSubFrameContent = await contentPage.spawn(
+ [extension.uuid],
+ uuid => {
+ return new Promise(resolve => {
+ let frame = this.content.document.createElement("iframe");
+ frame.setAttribute("src", `moz-extension://${uuid}/subframe.html`);
+ frame.onload = () => {
+ try {
+ void frame.contentWindow.indexedDB;
+ resolve({ success: true });
+ } catch (err) {
+ resolve({ error: `${err}` });
+ }
+ };
+ this.content.document.body.appendChild(frame);
+ });
+ }
+ );
+
+ Assert.deepEqual(
+ results.extTopLevel,
+ { success: true },
+ "IndexedDB allowed in a top level extension page"
+ );
+
+ Assert.deepEqual(
+ results.extSubFrame,
+ { success: true },
+ "IndexedDB allowed in a subframe extension page with a top level extension page"
+ );
+
+ Assert.deepEqual(
+ results.webSubFrame,
+ { error: "SecurityError: The operation is insecure." },
+ "IndexedDB not allowed in a subframe webpage with a top level extension page"
+ );
+ Assert.deepEqual(
+ results.webServiceWorker,
+ { error: "SecurityError: The operation is insecure." },
+ "IndexedDB and Cache not allowed in a service worker registered in the subframe webpage extension page"
+ );
+
+ Assert.deepEqual(
+ results.extSubFrameContent,
+ { success: true },
+ "IndexedDB allowed in a subframe extension page with a top level webpage"
+ );
+
+ await extensionPage.close();
+ await contentPage.close();
+
+ await extension.unload();
+ }
+);
+
+// Test that the webpage's indexedDB and localStorage are still not allowed from a content script
+// when the cookie behavior doesn't allow it, even when they are allowed in the extension pages.
+add_task(async function test_content_script_on_cookieBehaviorReject() {
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", BEHAVIOR_REJECT);
+
+ function contentScript() {
+ // Ensure that when the current cookieBehavior doesn't allow a webpage to use indexedDB
+ // or localStorage, then a WebExtension content script is not allowed to use it as well.
+ browser.test.assertThrows(
+ () => indexedDB,
+ /The operation is insecure/,
+ "a content script can't use indexedDB from a page where it is disallowed"
+ );
+
+ browser.test.assertThrows(
+ () => localStorage,
+ /The operation is insecure/,
+ "a content script can't use localStorage from a page where it is disallowed"
+ );
+
+ browser.test.notifyPass("cs_disallowed_storage");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ await extension.awaitFinish("cs_disallowed_storage");
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(function clear_cookieBehavior_pref() {
+ Services.prefs.clearUserPref("network.cookie.cookieBehavior");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_errors.js
new file mode 100644
index 0000000000..1c40f2f73f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_errors.js
@@ -0,0 +1,168 @@
+"use strict";
+
+add_task(async function setup_cookies() {
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ async background() {
+ const url = "http://example.com/";
+ const name = "dummyname";
+ await browser.cookies.set({ url, name, value: "from_setup:normal" });
+ await browser.cookies.set({
+ url,
+ name,
+ value: "from_setup:private",
+ storeId: "firefox-private",
+ });
+ await browser.cookies.set({
+ url,
+ name,
+ value: "from_setup:container",
+ storeId: "firefox-container-1",
+ });
+ browser.test.sendMessage("setup_done");
+ },
+ manifest: {
+ permissions: ["cookies", "http://example.com/"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("setup_done");
+ await extension.unload();
+});
+
+add_task(async function test_error_messages() {
+ async function background() {
+ const url = "http://example.com/";
+ const name = "dummyname";
+ // Shorthands to minimize boilerplate.
+ const set = d => browser.cookies.set({ url, name, value: "x", ...d });
+ const remove = d => browser.cookies.remove({ url, name, ...d });
+ const get = d => browser.cookies.get({ url, name, ...d });
+ const getAll = d => browser.cookies.getAll(d);
+
+ // Host permission permission missing.
+ await browser.test.assertRejects(
+ set({}),
+ /^Permission denied to set cookie \{.*\}$/,
+ "cookies.set without host permissions rejects with error"
+ );
+ browser.test.assertEq(
+ null,
+ await remove({}),
+ "cookies.remove without host permissions does not remove any cookies"
+ );
+ browser.test.assertEq(
+ null,
+ await get({}),
+ "cookies.get without host permissions does not match any cookies"
+ );
+ browser.test.assertEq(
+ "[]",
+ JSON.stringify(await getAll({})),
+ "cookies.getAll without host permissions does not match any cookies"
+ );
+
+ // Private browsing cookies without access to private browsing mode.
+ await browser.test.assertRejects(
+ set({ storeId: "firefox-private" }),
+ "Extension disallowed access to the private cookies storeId.",
+ "cookies.set cannot modify private cookies without permission"
+ );
+ await browser.test.assertRejects(
+ remove({ storeId: "firefox-private" }),
+ "Extension disallowed access to the private cookies storeId.",
+ "cookies.remove cannot modify private cookies without permission"
+ );
+ await browser.test.assertRejects(
+ get({ storeId: "firefox-private" }),
+ "Extension disallowed access to the private cookies storeId.",
+ "cookies.get cannot read private cookies without permission"
+ );
+ await browser.test.assertRejects(
+ getAll({ storeId: "firefox-private" }),
+ "Extension disallowed access to the private cookies storeId.",
+ "cookies.getAll cannot read private cookies without permission"
+ );
+
+ // On Android, any firefox-container-... is treated as valid, so it doesn't
+ // result in an error. However, because the test extension does not have
+ // any host permissions, it will fail with an error any way (but a
+ // different one than expected).
+ // TODO bug 1743616: Fix implementation and this test.
+ const kErrorInvalidContainer = navigator.userAgent.includes("Android")
+ ? /Permission denied to set cookie/
+ : `Invalid cookie store id: "firefox-container-99"`;
+
+ // Invalid storeId.
+ await browser.test.assertRejects(
+ set({ storeId: "firefox-container-99" }),
+ kErrorInvalidContainer,
+ "cookies.set with invalid storeId (non-existent container)"
+ );
+
+ await browser.test.assertRejects(
+ set({ storeId: "0" }),
+ `Invalid cookie store id: "0"`,
+ "cookies.set with invalid storeId (format not recognized)"
+ );
+
+ for (let method of [remove, get, getAll]) {
+ let resultWithInvalidStoreId = method == getAll ? [] : null;
+ browser.test.assertEq(
+ JSON.stringify(await method({ storeId: "firefox-container-99" })),
+ JSON.stringify(resultWithInvalidStoreId),
+ `cookies.${method.name} with invalid storeId (non-existent container)`
+ );
+
+ browser.test.assertEq(
+ JSON.stringify(await method({ storeId: "0" })),
+ JSON.stringify(resultWithInvalidStoreId),
+ `cookies.${method.name} with invalid storeId (format not recognized)`
+ );
+ }
+
+ browser.test.sendMessage("test_done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["cookies"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("test_done");
+ await extension.unload();
+});
+
+add_task(async function expected_cookies_at_end_of_test() {
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ async background() {
+ async function checkCookie(storeId, value) {
+ let cookies = await browser.cookies.getAll({ storeId });
+ let index = cookies.findIndex(c => c.value === value);
+ browser.test.assertTrue(index !== -1, `Found cookie: ${value}`);
+ if (index >= 0) {
+ cookies.splice(index, 1);
+ }
+ browser.test.assertEq(
+ "[]",
+ JSON.stringify(cookies),
+ `No more cookies left in cookieStoreId=${storeId}`
+ );
+ }
+ // Added in setup.
+ await checkCookie("firefox-default", "from_setup:normal");
+ await checkCookie("firefox-private", "from_setup:private");
+ await checkCookie("firefox-container-1", "from_setup:container");
+ browser.test.sendMessage("final_check_done");
+ },
+ manifest: {
+ permissions: ["cookies", "<all_urls>"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("final_check_done");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js
new file mode 100644
index 0000000000..700794b46c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js
@@ -0,0 +1,334 @@
+"use strict";
+
+const server = createHttpServer({
+ hosts: ["example.org", "example.net", "example.com"],
+});
+
+function promiseSetCookies() {
+ return new Promise(resolve => {
+ server.registerPathHandler("/setCookies", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.setHeader("Set-Cookie", "none=a; sameSite=none", true);
+ response.setHeader("Set-Cookie", "lax=b; sameSite=lax", true);
+ response.setHeader("Set-Cookie", "strict=c; sameSite=strict", true);
+ response.write("<html></html>");
+ resolve();
+ });
+ });
+}
+
+function promiseLoadedCookies() {
+ return new Promise(resolve => {
+ let cookies;
+
+ server.registerPathHandler("/checkCookies", (request, response) => {
+ cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : "";
+
+ response.setStatusLine(request.httpVersion, 302, "Moved Permanently");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.setHeader("Location", "/ready");
+ });
+
+ server.registerPathHandler("/navigate", (request, response) => {
+ cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : "";
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write(
+ "<html><script>location = '/checkCookies';</script></html>"
+ );
+ });
+
+ server.registerPathHandler("/fetch", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write("<html><script>fetch('/checkCookies');</script></html>");
+ });
+
+ server.registerPathHandler("/nestedfetch", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write(
+ "<html><iframe src='http://example.net/nestedfetch2'></iframe></html>"
+ );
+ });
+
+ server.registerPathHandler("/nestedfetch2", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write(
+ "<html><iframe src='http://example.org/fetch'></iframe></html>"
+ );
+ });
+
+ server.registerPathHandler("/ready", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write("<html></html>");
+
+ resolve(cookies);
+ });
+ });
+}
+
+add_task(async function setup() {
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", 0);
+ Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", true);
+
+ // We don't want to have 'secure' cookies because our test http server doesn't run in https.
+ Services.prefs.setBoolPref(
+ "network.cookie.sameSite.noneRequiresSecure",
+ false
+ );
+
+ // Let's set 3 cookies before loading the extension.
+ let cookiesPromise = promiseSetCookies();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.org/setCookies"
+ );
+ await cookiesPromise;
+ await contentPage.close();
+ Assert.equal(Services.cookies.cookies.length, 3);
+});
+
+add_task(async function test_cookies_firstParty() {
+ async function pageScript() {
+ const ifr = document.createElement("iframe");
+ ifr.src = "http://example.org/" + location.search.slice(1);
+ document.body.appendChild(ifr);
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["*://example.org/"],
+ },
+ files: {
+ "page.html": `<body><script src="page.js"></script></body>`,
+ "page.js": pageScript,
+ },
+ });
+
+ await extension.startup();
+
+ // This page will load example.org in an iframe.
+ let url = `moz-extension://${extension.uuid}/page.html`;
+ let cookiesPromise = promiseLoadedCookies();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ url + "?checkCookies",
+ { extension }
+ );
+
+ // Let's check the cookies received during the last loading.
+ Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c");
+ await contentPage.close();
+
+ // Let's navigate.
+ cookiesPromise = promiseLoadedCookies();
+ contentPage = await ExtensionTestUtils.loadContentPage(url + "?navigate", {
+ extension,
+ });
+
+ // Let's check the cookies received during the last loading.
+ Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c");
+ await contentPage.close();
+
+ // Let's run a fetch()
+ cookiesPromise = promiseLoadedCookies();
+ contentPage = await ExtensionTestUtils.loadContentPage(url + "?fetch", {
+ extension,
+ });
+
+ // Let's check the cookies received during the last loading.
+ Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c");
+ await contentPage.close();
+
+ // Let's run a fetch() from a nested iframe (extension -> example.net ->
+ // example.org -> fetch)
+ cookiesPromise = promiseLoadedCookies();
+ contentPage = await ExtensionTestUtils.loadContentPage(url + "?nestedfetch", {
+ extension,
+ });
+
+ // Let's check the cookies received during the last loading.
+ Assert.equal(await cookiesPromise, "none=a");
+ await contentPage.close();
+
+ // Let's run a fetch() from a nested iframe (extension -> example.org -> fetch)
+ cookiesPromise = promiseLoadedCookies();
+ contentPage = await ExtensionTestUtils.loadContentPage(
+ url + "?nestedfetch2",
+ {
+ extension,
+ }
+ );
+
+ // Let's check the cookies received during the last loading.
+ Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c");
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_cookies_iframes() {
+ server.registerPathHandler("/echocookies", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write(
+ request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""
+ );
+ });
+
+ server.registerPathHandler("/contentScriptHere", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write("<html></html>");
+ });
+
+ server.registerPathHandler("/pageWithFrames", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+
+ response.write(`
+ <html>
+ <iframe src="http://example.com/contentScriptHere"></iframe>
+ <iframe src="http://example.net/contentScriptHere"></iframe>
+ </html>
+ `);
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["*://example.org/"],
+ content_scripts: [
+ {
+ js: ["contentScript.js"],
+ matches: [
+ "*://example.com/contentScriptHere",
+ "*://example.net/contentScriptHere",
+ ],
+ run_at: "document_end",
+ all_frames: true,
+ },
+ ],
+ },
+ files: {
+ "contentScript.js": async () => {
+ const res = await fetch("http://example.org/echocookies");
+ const cookies = await res.text();
+ browser.test.assertEq(
+ "none=a",
+ cookies,
+ "expected cookies in content script"
+ );
+ browser.test.sendMessage("extfetch:" + location.hostname);
+ },
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/pageWithFrames"
+ );
+ await Promise.all([
+ extension.awaitMessage("extfetch:example.com"),
+ extension.awaitMessage("extfetch:example.net"),
+ ]);
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_cookies_background() {
+ async function background() {
+ const res = await fetch("http://example.org/echocookies", {
+ credentials: "include",
+ });
+ const cookies = await res.text();
+ browser.test.sendMessage("fetchcookies", cookies);
+ }
+
+ const tests = [
+ {
+ permissions: ["http://example.org/*"],
+ cookies: "none=a; lax=b; strict=c",
+ },
+ {
+ permissions: [],
+ cookies: "none=a",
+ },
+ ];
+
+ for (let test of tests) {
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: test.permissions,
+ },
+ });
+
+ server.registerPathHandler("/echocookies", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.setHeader(
+ "Access-Control-Allow-Origin",
+ `moz-extension://${extension.uuid}`,
+ false
+ );
+ response.setHeader("Access-Control-Allow-Credentials", "true", false);
+ response.write(
+ request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""
+ );
+ });
+
+ await extension.startup();
+ equal(
+ await extension.awaitMessage("fetchcookies"),
+ test.cookies,
+ "extension with permissions can see SameSite-restricted cookies"
+ );
+
+ await extension.unload();
+ }
+});
+
+add_task(async function test_cookies_contentScript() {
+ server.registerPathHandler("/empty", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write("<html><body></body></html>");
+ });
+
+ async function contentScript() {
+ let res = await fetch("http://example.org/checkCookies");
+ browser.test.assertEq(location.origin + "/ready", res.url, "request OK");
+ browser.test.sendMessage("fetch-done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ run_at: "document_end",
+ js: ["contentscript.js"],
+ matches: ["*://*/*"],
+ },
+ ],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+
+ let cookiesPromise = promiseLoadedCookies();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.org/empty"
+ );
+ await extension.awaitMessage("fetch-done");
+
+ // Let's check the cookies received during the last loading.
+ Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c");
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_onChanged.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_onChanged.js
new file mode 100644
index 0000000000..6eef222297
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_onChanged.js
@@ -0,0 +1,142 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+// In this test, we want to check the behavior of extensions without private
+// browsing access. Privileged add-ons automatically have private browsing
+// access, so make sure that the test add-ons are not privileged.
+AddonTestUtils.usePrivilegedSignatures = false;
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+
+ Services.prefs.setBoolPref("extensions.eventPages.enabled", true);
+});
+
+function createTestExtension({ privateAllowed }) {
+ return ExtensionTestUtils.loadExtension({
+ incognitoOverride: privateAllowed ? "spanning" : null,
+ manifest: {
+ permissions: ["cookies"],
+ host_permissions: ["https://example.com/"],
+ background: { persistent: false },
+ },
+ background() {
+ browser.cookies.onChanged.addListener(changeInfo => {
+ browser.test.sendMessage("cookie-event", changeInfo);
+ });
+ },
+ });
+}
+
+function addAndRemoveCookie({ isPrivate }) {
+ const cookie = {
+ name: "cookname",
+ value: "cookvalue",
+ domain: "example.com",
+ hostOnly: true,
+ path: "/",
+ secure: true,
+ httpOnly: false,
+ sameSite: "lax",
+ session: false,
+ firstPartyDomain: "",
+ partitionKey: null,
+ expirationDate: Date.now() + 3600000,
+ storeId: isPrivate ? "firefox-private" : "firefox-default",
+ };
+ const originAttributes = { privateBrowsingId: isPrivate ? 1 : 0 };
+ Services.cookies.add(
+ cookie.domain,
+ cookie.path,
+ cookie.name,
+ cookie.value,
+ cookie.secure,
+ cookie.httpOnly,
+ cookie.session,
+ cookie.expirationDate,
+ originAttributes,
+ Ci.nsICookie.SAMESITE_LAX,
+ Ci.nsICookie.SCHEME_HTTPS
+ );
+ Services.cookies.remove(
+ cookie.domain,
+ cookie.name,
+ cookie.path,
+ originAttributes
+ );
+ return cookie;
+}
+
+add_task(async function test_onChanged_event_page() {
+ let nonPrivateExtension = createTestExtension({ privateAllowed: false });
+ let privateExtension = createTestExtension({ privateAllowed: true });
+ await privateExtension.startup();
+ await nonPrivateExtension.startup();
+ assertPersistentListeners(privateExtension, "cookies", "onChanged", {
+ primed: false,
+ });
+ assertPersistentListeners(nonPrivateExtension, "cookies", "onChanged", {
+ primed: false,
+ });
+
+ // Suspend both event pages.
+ await privateExtension.terminateBackground();
+ assertPersistentListeners(privateExtension, "cookies", "onChanged", {
+ primed: true,
+ });
+ await nonPrivateExtension.terminateBackground();
+ assertPersistentListeners(nonPrivateExtension, "cookies", "onChanged", {
+ primed: true,
+ });
+
+ // Modifying a private cookie should wake up the private extension, but not
+ // the other one that does not have access to private browsing data.
+ let privateCookie = addAndRemoveCookie({ isPrivate: true });
+
+ Assert.deepEqual(
+ await privateExtension.awaitMessage("cookie-event"),
+ { removed: false, cookie: privateCookie, cause: "explicit" },
+ "cookies.onChanged for private cookie creation"
+ );
+ Assert.deepEqual(
+ await privateExtension.awaitMessage("cookie-event"),
+ { removed: true, cookie: privateCookie, cause: "explicit" },
+ "cookies.onChanged for private cookie removal"
+ );
+ // Private extension should have awakened...
+ assertPersistentListeners(privateExtension, "cookies", "onChanged", {
+ primed: false,
+ });
+ // ... but the non-private extension should still be sound asleep.
+ assertPersistentListeners(nonPrivateExtension, "cookies", "onChanged", {
+ primed: true,
+ });
+
+ // A non-private cookie modification should notify both extensions.
+ let nonPrivateCookie = addAndRemoveCookie({ isPrivate: false });
+ Assert.deepEqual(
+ await privateExtension.awaitMessage("cookie-event"),
+ { removed: false, cookie: nonPrivateCookie, cause: "explicit" },
+ "cookies.onChanged for cookie creation in privateExtension"
+ );
+ Assert.deepEqual(
+ await privateExtension.awaitMessage("cookie-event"),
+ { removed: true, cookie: nonPrivateCookie, cause: "explicit" },
+ "cookies.onChanged for cookie removal in privateExtension"
+ );
+ Assert.deepEqual(
+ await nonPrivateExtension.awaitMessage("cookie-event"),
+ { removed: false, cookie: nonPrivateCookie, cause: "explicit" },
+ "cookies.onChanged for cookie creation in nonPrivateExtension"
+ );
+ Assert.deepEqual(
+ await nonPrivateExtension.awaitMessage("cookie-event"),
+ { removed: true, cookie: nonPrivateCookie, cause: "explicit" },
+ "cookies.onChanged for cookie removal in nonPrivateCookie"
+ );
+
+ await privateExtension.unload();
+ await nonPrivateExtension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_partitionKey.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_partitionKey.js
new file mode 100644
index 0000000000..7a4170a51e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_partitionKey.js
@@ -0,0 +1,895 @@
+"use strict";
+
+/**
+ * This test verifies that the extension API's access to cookies is consistent
+ * with the cookies as seen by web pages under the following modes:
+ * - Every top-level document shares the same cookie jar, every subdocument of
+ * the top-level document has a distinct cookie jar tied to the site of the
+ * top-level document (dFPI).
+ * - All documents have a cookie jar keyed by the domain of the top-level
+ * document (FPI).
+ * - All cookies are in one cookie jar (classic behavior = no FPI nor dFPI)
+ *
+ * FPI and dFPI are implemented using OriginAttributes, and historically the
+ * consequence of not recognizing an origin attribute is that cookies cannot be
+ * deleted. Hence, the functionality of the cookies API is verified as follows,
+ * by the testCookiesAPI/runTestCase methods.
+ *
+ * 1. Load page that creates cookies for the top and a framed document:
+ * - "delete_me"
+ * - "edit_me"
+ * 2. cookies.getAll: get all cookies with extension API.
+ * 3. cookies.remove: Remove "delete_me" cookies with the extension API.
+ * 4. cookies.set: Edit "edit_me" cookie with the extension API.
+ * 5. Verify that the web page can see "edit_me" cookie (via document.cookie).
+ * 6. cookies.get: "edit_me" is still present.
+ * 7. cookies.remove: "edit_me" can be removed.
+ * 8. cookies.getAll: no cookies left.
+ */
+
+const FIRST_DOMAIN = "first.example.com";
+const FIRST_DOMAIN_ETLD_PLUS_1 = "example.com";
+const FIRST_DOMAIN_ETLD_PLUS_MANY = "nested.under.first.example.com";
+const THIRD_PARTY_DOMAIN = "third.example.net";
+const server = createHttpServer({
+ hosts: [FIRST_DOMAIN, FIRST_DOMAIN_ETLD_PLUS_MANY, THIRD_PARTY_DOMAIN],
+});
+const LOCAL_IP_AND_PORT = `127.0.0.1:${server.identity.primaryPort}`;
+
+server.registerPathHandler("/top", (request, response) => {
+ response.setHeader("Set-Cookie", `delete_me=top; SameSite=none`);
+ response.setHeader("Set-Cookie", `edit_me=top; SameSite=none`, true);
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write(
+ `<!DOCTYPE html><iframe src="//third.example.net/framed"></iframe>`
+ );
+});
+server.registerPathHandler("/framed", (request, response) => {
+ response.setHeader("Set-Cookie", `delete_me=frame; SameSite=none`);
+ response.setHeader("Set-Cookie", `edit_me=frame; SameSite=none`, true);
+});
+
+// Background script of the extension that drives the test.
+// It first waits for the content scripts in /top and /framed to connect,
+// in order to verify that cookie operations by the extension API are reflected
+// to the web page (verified through document.cookie from the content script).
+function backgroundScript() {
+ let portsByDomain = new Map();
+
+ async function getDocumentCookies(port) {
+ return new Promise(resolve => {
+ port.onMessage.addListener(function listener(cookieString) {
+ port.onMessage.removeListener(listener);
+ resolve(cookieString);
+ });
+ port.postMessage("get_cookies");
+ });
+ }
+
+ // Stringify cookie identifier for comparisons in assertions.
+ function stringifyCookie(cookie) {
+ if (!cookie) {
+ return "COOKIE MISSING";
+ }
+ let domain = cookie.domain;
+ if (!domain) {
+ // The return value of `cookies.remove` has a URL instead of a domain.
+ domain = new URL(cookie.url).hostname;
+ }
+ return `${cookie.name} domain=${domain} firstPartyDomain=${
+ cookie.firstPartyDomain
+ } partitionKey=${JSON.stringify(cookie.partitionKey)}`;
+ }
+ function stringifyCookies(cookies) {
+ return cookies.map(stringifyCookie).sort().join(" , ");
+ }
+
+ // detailsIn may have partitionKey and firstPartyDomain attributes.
+ // expectedOut has partitionKey and firstPartyDomain attributes.
+ async function runTestCase({ domain, detailsIn, expectedOut }) {
+ const port = portsByDomain.get(domain);
+ browser.test.assertTrue(port, `Got port to document for ${domain}`);
+
+ let allCookies = await browser.cookies.getAll({
+ domain,
+ firstPartyDomain: null,
+ partitionKey: {},
+ });
+
+ let allCookiesWithFPD = await browser.cookies.getAll({
+ domain,
+ ...detailsIn,
+ });
+ browser.test.assertEq(
+ stringifyCookies(allCookies),
+ stringifyCookies(allCookiesWithFPD),
+ "cookies.getAll returns consistent results"
+ );
+
+ for (let [key, expectedValue] of Object.entries(expectedOut)) {
+ expectedValue = JSON.stringify(expectedValue);
+ browser.test.assertTrue(
+ allCookies.every(c => JSON.stringify(c[key]) === expectedValue),
+ `All ${allCookies.length} cookies have ${key}=${expectedValue}`
+ );
+ }
+
+ // delete_me: get, remove, get.
+ const cookieToDelete = {
+ url: `http://${domain}/`,
+ name: "delete_me",
+ ...detailsIn,
+ };
+ const deletedCookie = {
+ ...cookieToDelete,
+ ...expectedOut,
+ };
+ browser.test.assertEq(
+ stringifyCookie(deletedCookie),
+ stringifyCookie(await browser.cookies.get(cookieToDelete)),
+ "delete_me cookie exists before removal"
+ );
+ browser.test.assertEq(
+ stringifyCookie(deletedCookie),
+ stringifyCookie(await browser.cookies.remove(cookieToDelete)),
+ "delete_me cookie has been removed by cookies.remove"
+ );
+ browser.test.assertEq(
+ null,
+ await browser.cookies.get(cookieToDelete),
+ "delete_me cookie does not exist any more"
+ );
+
+ // edit_me: set, retrieve via document.cookie
+ const cookieToEdit = {
+ url: `http://${domain}/`,
+ name: "edit_me",
+ ...detailsIn,
+ };
+ const editedCookie = await browser.cookies.set({
+ ...cookieToEdit,
+ value: `new_value_${domain}`,
+ });
+ browser.test.assertEq(
+ stringifyCookie({ ...cookieToEdit, ...expectedOut }),
+ stringifyCookie(editedCookie),
+ "edit_me cookie updated"
+ );
+ browser.test.assertEq(
+ await getDocumentCookies(port),
+ `edit_me=new_value_${domain}`,
+ "Expected cookies after removing and editing a cookie"
+ );
+
+ // edit_me: get, remove, getAll.
+ browser.test.assertEq(
+ stringifyCookie(editedCookie),
+ stringifyCookie(await browser.cookies.get(cookieToEdit)),
+ "edit_me cookie still exists"
+ );
+ await browser.cookies.remove(cookieToEdit);
+ let allCookiesAtEnd = await browser.cookies.getAll({
+ domain,
+ firstPartyDomain: null,
+ partitionKey: {},
+ });
+ browser.test.assertEq(
+ "[]",
+ JSON.stringify(allCookiesAtEnd),
+ "No cookies left"
+ );
+ }
+
+ let resolveTestReady;
+ let testReadyPromise = new Promise(resolve => {
+ resolveTestReady = resolve;
+ });
+
+ browser.test.onMessage.addListener(async (msg, testCase) => {
+ await testReadyPromise;
+ browser.test.assertEq("runTest", msg, `Starting: ${testCase.description}`);
+ try {
+ await runTestCase(testCase);
+ } catch (e) {
+ browser.test.fail(`Unexpected error: ${e} :: ${e.stack}`);
+ }
+ browser.test.sendMessage("runTest_done");
+ });
+
+ // cookie-checker-contentscript.js will connect.
+ browser.runtime.onConnect.addListener(port => {
+ portsByDomain.set(port.name, port);
+ browser.test.log(`Got port #${portsByDomain.size} ${port.name}`);
+ if (portsByDomain.size === 2) {
+ // The top document and the embedded frame has loaded and the
+ // content script that we use to read cookies is connected.
+ // The test can now start.
+ resolveTestReady();
+ }
+ });
+}
+
+// The primary purpose of this test is to verify that the cookies API can read
+// and write cookies that are actually in use by the web page.
+async function testCookiesAPI({ testCases, topDomain = FIRST_DOMAIN }) {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: backgroundScript,
+ manifest: {
+ permissions: [
+ "cookies",
+ // Remove port to work around bug 1350523.
+ `*://${topDomain.replace(/:\d+$/, "")}/*`,
+ `*://${THIRD_PARTY_DOMAIN}/*`,
+ ],
+ content_scripts: [
+ {
+ js: ["cookie-checker-contentscript.js"],
+ matches: [
+ // Remove port to work around bug 1362809.
+ `*://${topDomain.replace(/:\d+$/, "")}/top`,
+ `*://${THIRD_PARTY_DOMAIN}/framed`,
+ ],
+ all_frames: true,
+ run_at: "document_end",
+ },
+ ],
+ },
+ files: {
+ "cookie-checker-contentscript.js": () => {
+ const port = browser.runtime.connect({ name: location.hostname });
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, "get_cookies", "Expected port message");
+ port.postMessage(document.cookie);
+ });
+ },
+ },
+ });
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `http://${topDomain}/top`
+ );
+ for (let testCase of testCases) {
+ info(`Running test case: ${testCase.description}`);
+ extension.sendMessage("runTest", testCase);
+ await extension.awaitMessage("runTest_done");
+ }
+ await contentPage.close();
+ await extension.unload();
+}
+
+add_task(async function setup() {
+ // SameSite=none is needed to set cookies in third-party contexts.
+ // SameSite=none usually requires Secure, but the test server doesn't support
+ // https, so disable the Secure requirement for SameSite=none.
+ Services.prefs.setBoolPref(
+ "network.cookie.sameSite.noneRequiresSecure",
+ false
+ );
+});
+
+add_task(async function test_no_partitioning() {
+ const testCases = [
+ {
+ description: "first-party cookies without any partitioning",
+ domain: FIRST_DOMAIN,
+ detailsIn: {
+ firstPartyDomain: "",
+ partitionKey: null,
+ },
+ expectedOut: {
+ firstPartyDomain: "",
+ partitionKey: null,
+ },
+ },
+ {
+ description: "third-party cookies without any partitioning",
+ domain: THIRD_PARTY_DOMAIN,
+ detailsIn: {
+ // Without (d)FPI, firstPartyDomain and partitionKey are optional.
+ },
+ expectedOut: {
+ firstPartyDomain: "",
+ partitionKey: null,
+ },
+ },
+ ];
+ await runWithPrefs(
+ // dFPI is enabled by default on Nightly, disable it.
+ [["network.cookie.cookieBehavior", 4]],
+ () => testCookiesAPI({ testCases })
+ );
+});
+
+add_task(async function test_firstPartyIsolate() {
+ const testCases = [
+ {
+ description: "first-party cookies with FPI",
+ domain: FIRST_DOMAIN,
+ detailsIn: {
+ firstPartyDomain: FIRST_DOMAIN_ETLD_PLUS_1,
+ },
+ expectedOut: {
+ firstPartyDomain: FIRST_DOMAIN_ETLD_PLUS_1,
+ partitionKey: null,
+ },
+ },
+ {
+ description: "third-party cookies with FPI",
+ domain: THIRD_PARTY_DOMAIN,
+ detailsIn: {
+ firstPartyDomain: FIRST_DOMAIN_ETLD_PLUS_1,
+ },
+ expectedOut: {
+ firstPartyDomain: FIRST_DOMAIN_ETLD_PLUS_1,
+ partitionKey: null,
+ },
+ },
+ ];
+ await runWithPrefs(
+ [
+ // FPI is mutually exclusive with dFPI. Disable dFPI.
+ ["network.cookie.cookieBehavior", 4],
+ ["privacy.firstparty.isolate", true],
+ ],
+ () => testCookiesAPI({ testCases })
+ );
+});
+
+add_task(async function test_dfpi() {
+ const testCases = [
+ {
+ description: "first-party cookies with dFPI",
+ domain: FIRST_DOMAIN,
+ detailsIn: {
+ // partitionKey is optional and expected to default to unpartitioned.
+ },
+ expectedOut: {
+ firstPartyDomain: "",
+ partitionKey: null,
+ },
+ },
+ {
+ description: "third-party cookies with dFPI",
+ domain: THIRD_PARTY_DOMAIN,
+ detailsIn: {
+ partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_1}` },
+ },
+ expectedOut: {
+ firstPartyDomain: "",
+ partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_1}` },
+ },
+ },
+ ];
+ await runWithPrefs(
+ // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN.
+ [["network.cookie.cookieBehavior", 5]],
+ () => testCookiesAPI({ testCases })
+ );
+});
+
+add_task(async function test_dfpi_with_ip_and_port() {
+ const testCases = [
+ {
+ description: "first-party cookies for IP with port",
+ domain: "127.0.0.1",
+ detailsIn: {
+ partitionKey: null,
+ },
+ expectedOut: {
+ firstPartyDomain: "",
+ partitionKey: null,
+ },
+ },
+ {
+ description: "third-party cookies for IP with port",
+ domain: THIRD_PARTY_DOMAIN,
+ detailsIn: {
+ partitionKey: { topLevelSite: `http://${LOCAL_IP_AND_PORT}` },
+ },
+ expectedOut: {
+ firstPartyDomain: "",
+ partitionKey: { topLevelSite: `http://${LOCAL_IP_AND_PORT}` },
+ },
+ },
+ ];
+ await runWithPrefs(
+ // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN.
+ [["network.cookie.cookieBehavior", 5]],
+ () => testCookiesAPI({ testCases, topDomain: LOCAL_IP_AND_PORT })
+ );
+});
+
+add_task(async function test_dfpi_with_nested_subdomains() {
+ const testCases = [
+ {
+ description: "first-party cookies with DFPI at eTLD+many",
+ domain: FIRST_DOMAIN_ETLD_PLUS_MANY,
+ detailsIn: {
+ partitionKey: null,
+ },
+ expectedOut: {
+ firstPartyDomain: "",
+ partitionKey: null,
+ },
+ },
+ {
+ description: "third-party cookies for first party with eTLD+many",
+ domain: THIRD_PARTY_DOMAIN,
+ detailsIn: {
+ // Partitioned cookies are keyed by eTLD+1, so even if eTLD+many is
+ // passed, then eTLD+1 is stored (and returned).
+ partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_MANY}` },
+ },
+ expectedOut: {
+ firstPartyDomain: "",
+ partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_1}` },
+ },
+ },
+ ];
+ await runWithPrefs(
+ // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN.
+ [["network.cookie.cookieBehavior", 5]],
+ () => testCookiesAPI({ testCases, topDomain: FIRST_DOMAIN_ETLD_PLUS_MANY })
+ );
+});
+
+add_task(async function test_dfpi_with_non_default_use_site() {
+ // privacy.dynamic_firstparty.use_site is a pref that can be used to toggle
+ // the internal representation of partitionKey. True (default) means keyed
+ // by site (scheme, host, port); false means keyed by host only.
+ const testCases = [
+ {
+ description: "first-party cookies with dFPI and use_site=false",
+ domain: FIRST_DOMAIN,
+ detailsIn: {
+ partitionKey: null,
+ },
+ expectedOut: {
+ firstPartyDomain: "",
+ partitionKey: null,
+ },
+ },
+ {
+ description: "third-party cookies with dFPI and use_site=false",
+ domain: THIRD_PARTY_DOMAIN,
+ detailsIn: {
+ partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_1}` },
+ },
+ expectedOut: {
+ firstPartyDomain: "",
+ // When use_site=false, the scheme is not stored, and the
+ // implementation just prepends "https" as a dummy scheme.
+ partitionKey: { topLevelSite: `https://${FIRST_DOMAIN_ETLD_PLUS_1}` },
+ },
+ },
+ ];
+ await runWithPrefs(
+ [
+ // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN.
+ ["network.cookie.cookieBehavior", 5],
+ ["privacy.dynamic_firstparty.use_site", false],
+ ],
+ () => testCookiesAPI({ testCases })
+ );
+});
+add_task(async function test_dfpi_with_ip_and_port_and_non_default_use_site() {
+ // privacy.dynamic_firstparty.use_site is a pref that can be used to toggle
+ // the internal representation of partitionKey. True (default) means keyed
+ // by site (scheme, host, port); false means keyed by host only.
+ const testCases = [
+ {
+ description: "first-party cookies for IP:port with dFPI+use_site=false",
+ domain: "127.0.0.1",
+ detailsIn: {
+ partitionKey: null,
+ },
+ expectedOut: {
+ firstPartyDomain: "",
+ partitionKey: null,
+ },
+ },
+ {
+ description: "third-party cookies for IP:port with dFPI+use_site=false",
+ domain: THIRD_PARTY_DOMAIN,
+ detailsIn: {
+ // When use_site=false, the scheme is not stored in the internal
+ // representation of the partitionKey. So even though the web page
+ // creates the cookie at HTTP, the cookies are still detected when
+ // "https" is used.
+ partitionKey: { topLevelSite: `https://${LOCAL_IP_AND_PORT}` },
+ },
+ expectedOut: {
+ firstPartyDomain: "",
+ // When use_site=false, the scheme and port are not stored.
+ // "https" is used as a dummy scheme, and the port is not used.
+ partitionKey: { topLevelSite: "https://127.0.0.1" },
+ },
+ },
+ ];
+ await runWithPrefs(
+ [
+ // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN.
+ ["network.cookie.cookieBehavior", 5],
+ ["privacy.dynamic_firstparty.use_site", false],
+ ],
+ () => testCookiesAPI({ testCases, topDomain: LOCAL_IP_AND_PORT })
+ );
+});
+
+add_task(async function dfpi_invalid_partitionKey() {
+ AddonTestUtils.init(globalThis);
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+ );
+ // The test below uses the browser.privacy API, which relies on
+ // ExtensionSettingsStore, which in turn depends on AddonManager.
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ permissions: ["cookies", "*://example.com/*", "privacy"],
+ },
+ async background() {
+ const url = "http://example.com/";
+ const name = "dfpi_invalid_partitionKey_dummy_name";
+ const value = "1";
+
+ // Shorthands to minimize boilerplate.
+ const set = d => browser.cookies.set({ url, name, value, ...d });
+ const remove = d => browser.cookies.remove({ url, name, ...d });
+ const get = d => browser.cookies.get({ url, name, ...d });
+ const getAll = d => browser.cookies.getAll(d);
+
+ await browser.test.assertRejects(
+ set({ partitionKey: { topLevelSite: "example.net" } }),
+ /Invalid value for 'partitionKey' attribute/,
+ "partitionKey must be a URL, not a domain"
+ );
+ await browser.test.assertRejects(
+ set({ partitionKey: { topLevelSite: "chrome://foo" } }),
+ /Invalid value for 'partitionKey' attribute/,
+ "partitionKey cannot be the chrome:-scheme (canonicalization fails)"
+ );
+ await browser.test.assertRejects(
+ set({ partitionKey: { topLevelSite: "chrome://foo/foo/foo" } }),
+ /Invalid value for 'partitionKey' attribute/,
+ "partitionKey cannot be the chrome:-scheme (canonicalization passes)"
+ );
+ await browser.test.assertRejects(
+ set({ partitionKey: { topLevelSite: "http://[]:" } }),
+ /Invalid value for 'partitionKey' attribute/,
+ "partitionKey must be a valid URL"
+ );
+
+ browser.test.assertThrows(
+ () => get({ partitionKey: "" }),
+ /Error processing partitionKey: Expected object instead of ""/,
+ "cookies.get should reject invalid partitionKey (string)"
+ );
+ browser.test.assertThrows(
+ () => get({ partitionKey: { topLevelSite: "http://x", badkey: 0 } }),
+ /Error processing partitionKey: Unexpected property "badkey"/,
+ "cookies.get should reject unsupported keys in partitionKey"
+ );
+ await browser.test.assertRejects(
+ remove({ partitionKey: { topLevelSite: "invalid" } }),
+ /Invalid value for 'partitionKey' attribute/,
+ "cookies.remove should reject invalid partitionKey.topLevelSite"
+ );
+ await browser.test.assertRejects(
+ get({ partitionKey: { topLevelSite: "invalid" } }),
+ /Invalid value for 'partitionKey' attribute/,
+ "cookies.get should reject invalid partitionKey.topLevelSite"
+ );
+ await browser.test.assertRejects(
+ getAll({ partitionKey: { topLevelSite: "invalid" } }),
+ /Invalid value for 'partitionKey' attribute/,
+ "cookies.getAll should reject invalid partitionKey.topLevelSite"
+ );
+
+ // firstPartyDomain and partitionKey are mutually exclusive, because
+ // FPI and dFPI are mutually exclusive.
+ await browser.test.assertRejects(
+ set({ firstPartyDomain: "example.net", partitionKey: {} }),
+ /Partitioned cookies cannot have a 'firstPartyDomain' attribute./,
+ "partitionKey and firstPartyDomain cannot both be non-empty"
+ );
+
+ // On Nightly, dFPI is enabled by default. We have to disable it first,
+ // before we can enable FPI. Otherwise we would get error:
+ // Can't enable firstPartyIsolate when cookieBehavior is 'reject_trackers_and_partition_foreign'
+ await browser.privacy.websites.cookieConfig.set({
+ value: { behavior: "reject_trackers" },
+ });
+ await browser.privacy.websites.firstPartyIsolate.set({
+ value: true,
+ });
+
+ // FPI and dFPI are mutually exclusive. FPI is documented to require the
+ // firstPartyDomain attribute, let's verify that, despite it being
+ // technically possible to support both attributes.
+ for (let cookiesMethod of [get, getAll, remove, set]) {
+ await browser.test.assertRejects(
+ cookiesMethod({ partitionKey: { topLevelSite: url } }),
+ /First-Party Isolation is enabled, but the required 'firstPartyDomain' attribute was not set./,
+ `cookies.${cookiesMethod.name} requires firstPartyDomain when FPI is enabled`
+ );
+ }
+
+ // The pref changes above (to dFPI/FPI) via the browser.privacy API will
+ // be undone when the extension unloads.
+
+ browser.test.sendMessage("test_done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("test_done");
+ await extension.unload();
+
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+add_task(async function dfpi_moz_extension() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies", "*://example.com/*"],
+ },
+ async background() {
+ let cookie = await browser.cookies.set({
+ url: "http://example.com/",
+ name: "moz_ext_party",
+ value: "1",
+ // moz-extension: URL is passed here, in an attempt to mark the cookie
+ // as part of the "moz-extension:"-partition. Below we will expect ""
+ // because the dFPI implementation treats "moz-extension" as
+ // unpartitioned, see
+ // https://searchfox.org/mozilla-central/rev/ac7da6c7306d86e2f86a302ce1e170ad54b3c1fe/caps/OriginAttributes.cpp#79-82
+ partitionKey: { topLevelSite: browser.runtime.getURL("/") },
+ });
+ browser.test.assertEq(
+ null,
+ cookie.partitionKey,
+ "Cookies in moz-extension:-URL are unpartitioned"
+ );
+ let deletedCookie = await browser.cookies.remove({
+ url: "http://example.com/",
+ name: "moz_ext_party",
+ partitionKey: { topLevelSite: "moz-extension://ignoreme" },
+ });
+ browser.test.assertEq(
+ null,
+ deletedCookie.partitionKey,
+ "moz-extension:-partition key is treated as unpartitioned"
+ );
+ browser.test.sendMessage("test_done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("test_done");
+ await extension.unload();
+});
+
+add_task(async function dfpi_about_scheme_as_partitionKey() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies", "*://example.com/*"],
+ },
+ async background() {
+ let cookie = await browser.cookies.set({
+ url: "http://example.com/",
+ name: "moz_ext_party",
+ value: "1",
+ partitionKey: { topLevelSite: "about:blank" },
+ });
+ // It doesn't really make sense to partition in `about:blank` (since it
+ // cannot really be a first party), but for completeness of test coverage
+ // we also check that the use of an about:-scheme results in predictable
+ // behavior. The weird "about://"-URL below is the serialization of the
+ // internal value of the partitionKey attribute:
+ // https://searchfox.org/mozilla-central/rev/ac7da6c7306d86e2f86a302ce1e170ad54b3c1fe/caps/OriginAttributes.cpp#73-77
+ browser.test.assertEq(
+ "about://about.ef2a7dd5-93bc-417f-a698-142c3116864f.mozilla",
+ cookie.partitionKey.topLevelSite,
+ "An URL-like representation of the internal about:-format is returned"
+ );
+ let deletedCookie = await browser.cookies.remove({
+ url: "http://example.com/",
+ name: "moz_ext_party",
+ partitionKey: {
+ topLevelSite:
+ "about://about.ef2a7dd5-93bc-417f-a698-142c3116864f.mozilla",
+ },
+ });
+ browser.test.assertEq(
+ "about://about.ef2a7dd5-93bc-417f-a698-142c3116864f.mozilla",
+ deletedCookie.partitionKey.topLevelSite,
+ "Cookie can be deleted via the dummy about:-scheme"
+ );
+ browser.test.sendMessage("test_done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("test_done");
+ await extension.unload();
+});
+
+// Same-site frames are expected to be unpartitioned.
+// The cookies API can receive partitionKey and url that are same-site. While
+// such cookies won't be sent to websites in practice, we do want to verify that
+// the behavior is predictable.
+add_task(async function test_url_is_same_site_as_partitionKey() {
+ // This loads a page with a frame at third.example.net (= THIRD_PARTY_DOMAIN).
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `http://${THIRD_PARTY_DOMAIN}/top`
+ );
+ await contentPage.close();
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies", "*://third.example.net/"],
+ },
+ async background() {
+ // Retrieve all cookies, partitioned and unpartitioned. We expect only
+ // unpartitioned cookies at first because the top frame and the child
+ // frame have the same origin.
+ let initialCookies = await browser.cookies.getAll({ partitionKey: {} });
+ browser.test.assertEq(
+ "delete_me=frame,edit_me=frame",
+ initialCookies.map(c => `${c.name}=${c.value}`).join(),
+ "Same-site frames are in unpartitioned storage; /frame overwrites /top"
+ );
+ browser.test.assertTrue(
+ await browser.cookies.remove({
+ url: "https://third.example.net/",
+ name: "delete_me",
+ }),
+ "Removed unpartitioned cookie"
+ );
+ browser.test.assertEq(
+ "[null,null]",
+ JSON.stringify(initialCookies.map(c => c.partitionKey)),
+ "Cookies in same-site/same-origin frames are not partitioned"
+ );
+
+ // We only have one unpartitioned cookie (edit_cookie) left.
+
+ // Add new cookie whose partitionKey is same-site relative to url.
+ let newCookie = await browser.cookies.set({
+ url: "http://third.example.net/",
+ name: "edit_me",
+ value: "url_is_partitionKey_eTLD+2",
+ partitionKey: { topLevelSite: "http://third.example.net" },
+ });
+ browser.test.assertEq(
+ "http://example.net",
+ newCookie.partitionKey.topLevelSite,
+ "Created cookie with partitionKey=url; eTLD+2 is normalized as eTLD+1"
+ );
+
+ browser.test.assertTrue(
+ await browser.cookies.remove({
+ url: "http://third.example.net/",
+ name: "edit_me",
+ partitionKey: {},
+ }),
+ "Removed unpartitioned cookie when partitionKey: {} is used"
+ );
+
+ browser.test.assertEq(
+ null,
+ await browser.cookies.remove({
+ url: "http://third.example.net/",
+ name: "edit_me",
+ partitionKey: {},
+ }),
+ "No more unpartitioned cookies to remove"
+ );
+
+ browser.test.assertTrue(
+ await browser.cookies.remove({
+ url: "http://third.example.net/",
+ name: "edit_me",
+ partitionKey: { topLevelSite: "http://example.net" },
+ }),
+ "Removed partitioned cookie when partitionKey is passed"
+ );
+
+ browser.test.sendMessage("test_done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("test_done");
+ await extension.unload();
+});
+
+add_task(async function test_getAll_partitionKey() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["cookies", "*://third.example.net/"],
+ },
+ async background() {
+ const url = "http://third.example.net";
+ const name = "test_url_is_identical_to_partitionKey";
+ const partitionKey = { topLevelSite: "http://example.com" };
+ const firstPartyDomain = "example.net";
+
+ // Create non-partitioned cookie, create partitioned cookie.
+ await browser.cookies.set({ url, name, value: "no_partition" });
+ await browser.cookies.set({ url, name, value: "fpd", firstPartyDomain });
+ await browser.cookies.set({ url, name, partitionKey, value: "party" });
+ // partitionKey + firstPartyDomain was tested in dfpi_invalid_partitionKey
+
+ async function getAllValues(details) {
+ let cookies = await browser.cookies.getAll(details);
+ let values = cookies.map(c => c.value);
+ return values.sort().join(); // Serialize for use with assertEq.
+ }
+
+ browser.test.assertEq(
+ "no_partition",
+ await getAllValues({}),
+ "getAll() returns unpartitioned by default"
+ );
+
+ browser.test.assertEq(
+ "no_partition,party",
+ await getAllValues({ partitionKey: {} }),
+ "getAll() with partitionKey: {} returns all cookies"
+ );
+
+ browser.test.assertEq(
+ "party",
+ await getAllValues({ partitionKey }),
+ "getAll() with specific partitionKey returns partitionKey cookies only"
+ );
+
+ browser.test.assertEq(
+ "",
+ await getAllValues({ partitionKey: { topLevelSite: url } }),
+ "getAll() with partitionKey set to cookie URL does not match anything"
+ );
+
+ browser.test.assertEq(
+ "",
+ await getAllValues({ partitionKey, firstPartyDomain }),
+ "getAll() with non-empty partitionKey and firstPartyDomain does not match anything"
+ );
+ browser.test.assertEq(
+ "fpd",
+ await getAllValues({ partitionKey: {}, firstPartyDomain }),
+ "getAll() with empty partitionKey and firstPartyDomain matches fpd"
+ );
+
+ browser.test.assertEq(
+ "fpd,no_partition,party",
+ await getAllValues({ partitionKey: {}, firstPartyDomain: null }),
+ "getAll() with empty partitionKey and firstPartyDomain:null matches everything"
+ );
+
+ await browser.cookies.remove({ url, name });
+ await browser.cookies.remove({ url, name, firstPartyDomain });
+ await browser.cookies.remove({ url, name, partitionKey });
+
+ browser.test.sendMessage("test_done");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("test_done");
+ await extension.unload();
+});
+
+add_task(async function no_unexpected_cookies_at_end_of_test() {
+ let results = [];
+ for (const cookie of Services.cookies.cookies) {
+ results.push({
+ name: cookie.name,
+ value: cookie.value,
+ host: cookie.host,
+ originAttributes: cookie.originAttributes,
+ });
+ }
+ Assert.deepEqual(results, [], "Test should not leave any cookies");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js
new file mode 100644
index 0000000000..618ed820d4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js
@@ -0,0 +1,114 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.org"] });
+server.registerPathHandler("/sameSiteCookiesApiTest", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_samesite_cookies() {
+ // Bug 1617611 - Fix all the tests broken by "cookies SameSite=Lax by default"
+ Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", false);
+
+ function contentScript() {
+ document.cookie = "test1=whatever";
+ document.cookie = "test2=whatever; SameSite=lax";
+ document.cookie = "test3=whatever; SameSite=strict";
+ browser.runtime.sendMessage("do-check-cookies");
+ }
+ async function background() {
+ await new Promise(resolve => {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.assertEq("do-check-cookies", msg, "expected message");
+ resolve();
+ });
+ });
+
+ const url = "https://example.org/";
+
+ // Baseline. Every cookie must have the expected sameSite.
+ let cookie = await browser.cookies.get({ url, name: "test1" });
+ browser.test.assertEq(
+ "no_restriction",
+ cookie.sameSite,
+ "Expected sameSite for test1"
+ );
+
+ cookie = await browser.cookies.get({ url, name: "test2" });
+ browser.test.assertEq(
+ "lax",
+ cookie.sameSite,
+ "Expected sameSite for test2"
+ );
+
+ cookie = await browser.cookies.get({ url, name: "test3" });
+ browser.test.assertEq(
+ "strict",
+ cookie.sameSite,
+ "Expected sameSite for test3"
+ );
+
+ // Testing cookies.getAll + cookies.set
+ let cookies = await browser.cookies.getAll({ url, name: "test3" });
+ browser.test.assertEq(1, cookies.length, "There is only one test3 cookie");
+
+ cookie = await browser.cookies.set({
+ url,
+ name: "test3",
+ value: "newvalue",
+ });
+ browser.test.assertEq(
+ "no_restriction",
+ cookie.sameSite,
+ "sameSite defaults to no_restriction"
+ );
+
+ for (let sameSite of ["no_restriction", "lax", "strict"]) {
+ cookie = await browser.cookies.set({ url, name: "test3", sameSite });
+ browser.test.assertEq(
+ sameSite,
+ cookie.sameSite,
+ `Expected sameSite=${sameSite} in return value of cookies.set`
+ );
+ cookies = await browser.cookies.getAll({ url, name: "test3" });
+ browser.test.assertEq(
+ 1,
+ cookies.length,
+ `test3 is still the only cookie after setting sameSite=${sameSite}`
+ );
+ browser.test.assertEq(
+ sameSite,
+ cookies[0].sameSite,
+ `test3 was updated to sameSite=${sameSite}`
+ );
+ }
+
+ browser.test.notifyPass("cookies");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["cookies", "*://example.org/"],
+ content_scripts: [
+ {
+ matches: ["*://example.org/sameSiteCookiesApiTest*"],
+ js: ["contentscript.js"],
+ },
+ ],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.org/sameSiteCookiesApiTest"
+ );
+ await extension.awaitFinish("cookies");
+ await contentPage.close();
+ await extension.unload();
+
+ Services.prefs.clearUserPref("network.cookie.sameSite.laxByDefault");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cors_mozextension.js b/toolkit/components/extensions/test/xpcshell/test_ext_cors_mozextension.js
new file mode 100644
index 0000000000..fc50388c77
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_cors_mozextension.js
@@ -0,0 +1,220 @@
+"use strict";
+
+const server = createHttpServer({
+ hosts: ["example.com", "x.example.com"],
+});
+server.registerPathHandler("/dummy", (req, res) => {
+ res.write("dummy");
+});
+server.registerPathHandler("/redir", (req, res) => {
+ res.setStatusLine(req.httpVersion, 302, "Found");
+ res.setHeader("Access-Control-Allow-Origin", "http://example.com");
+ res.setHeader("Access-Control-Allow-Credentials", "true");
+ res.setHeader("Location", new URLSearchParams(req.queryString).get("url"));
+});
+
+add_task(async function load_moz_extension_with_and_without_cors() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ web_accessible_resources: ["ok.js"],
+ },
+ files: {
+ "ok.js": "window.status = 'loaded';",
+ "deny.js": "window.status = 'unexpected load'",
+ },
+ });
+ await extension.startup();
+ const EXT_BASE_URL = `moz-extension://${extension.uuid}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+ await contentPage.spawn([EXT_BASE_URL], async EXT_BASE_URL => {
+ const { document, window } = this.content;
+ async function checkScriptLoad({ setupScript, expectLoad, description }) {
+ const scriptElem = document.createElement("script");
+ setupScript(scriptElem);
+ return new Promise(resolve => {
+ window.status = "initial";
+ scriptElem.onload = () => {
+ Assert.equal(window.status, "loaded", "Script executed upon load");
+ Assert.ok(expectLoad, `Script loaded - ${description}`);
+ resolve();
+ };
+ scriptElem.onerror = () => {
+ Assert.equal(window.status, "initial", "not executed upon error");
+ Assert.ok(!expectLoad, `Script not loaded - ${description}`);
+ resolve();
+ };
+ document.head.append(scriptElem);
+ });
+ }
+
+ function sameOriginRedirectUrl(url) {
+ return `http://example.com/redir?url=` + encodeURIComponent(url);
+ }
+ function crossOriginRedirectUrl(url) {
+ return `http://x.example.com/redir?url=` + encodeURIComponent(url);
+ }
+
+ // Direct load of web-accessible extension script.
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = `${EXT_BASE_URL}/ok.js`;
+ },
+ expectLoad: true,
+ description: "web-accessible script, plain load",
+ });
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = `${EXT_BASE_URL}/ok.js`;
+ scriptElem.crossOrigin = "anonymous";
+ },
+ expectLoad: true,
+ description: "web-accessible script, cors",
+ });
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = `${EXT_BASE_URL}/ok.js`;
+ scriptElem.crossOrigin = "use-credentials";
+ },
+ expectLoad: true,
+ description: "web-accessible script, cors+credentials",
+ });
+
+ // Load of web-accessible extension scripts, after same-origin redirect.
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`);
+ },
+ expectLoad: true,
+ description: "same-origin redirect to web-accessible script, plain load",
+ });
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`);
+ scriptElem.crossOrigin = "anonymous";
+ },
+ expectLoad: true,
+ description: "same-origin redirect to web-accessible script, cors",
+ });
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`);
+ scriptElem.crossOrigin = "use-credentials";
+ },
+ expectLoad: true,
+ description:
+ "same-origin redirect to web-accessible script, cors+credentials",
+ });
+
+ // Load of web-accessible extension scripts, after cross-origin redirect.
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`);
+ },
+ expectLoad: true,
+ description: "cross-origin redirect to web-accessible script, plain load",
+ });
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`);
+ scriptElem.crossOrigin = "anonymous";
+ },
+ expectLoad: true,
+ description: "cross-origin redirect to web-accessible script, cors",
+ });
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`);
+ scriptElem.crossOrigin = "use-credentials";
+ },
+ expectLoad: true,
+ description:
+ "cross-origin redirect to web-accessible script, cors+credentials",
+ });
+
+ // Various loads of non-web-accessible extension script.
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = `${EXT_BASE_URL}/deny.js`;
+ },
+ expectLoad: false,
+ description: "non-accessible script, plain load",
+ });
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = `${EXT_BASE_URL}/deny.js`;
+ scriptElem.crossOrigin = "anonymous";
+ },
+ expectLoad: false,
+ description: "non-accessible script, cors",
+ });
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/deny.js`);
+ scriptElem.crossOrigin = "anonymous";
+ },
+ expectLoad: false,
+ description: "same-origin redirect to non-accessible script, cors",
+ });
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/deny.js`);
+ scriptElem.crossOrigin = "anonymous";
+ },
+ expectLoad: false,
+ description: "cross-origin redirect to non-accessible script, cors",
+ });
+
+ // Sub-resource integrity usually requires CORS. Verify that web-accessible
+ // extension resources are still subjected to SRI.
+ const sriHashOkJs = // SRI hash for "window.status = 'loaded';" (=ok.js).
+ "sha384-EAofaAZpgy6JshegITJJHeE3ROzn9ngGw1GAuuzjSJV1c/YS9PLvHMt9oh4RovrI";
+
+ async function testSRI({ integrityMatches }) {
+ const integrity = integrityMatches ? sriHashOkJs : "sha384-bad-sri-hash";
+ const sriDescription = integrityMatches
+ ? "web-accessible script, good sri, "
+ : "web-accessible script, sri not matching, ";
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = `${EXT_BASE_URL}/ok.js`;
+ scriptElem.integrity = integrity;
+ },
+ expectLoad: integrityMatches,
+ description: `${sriDescription} no cors, plain load`,
+ });
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = `${EXT_BASE_URL}/ok.js`;
+ scriptElem.crossOrigin = "anonymous";
+ scriptElem.integrity = integrity;
+ },
+ expectLoad: integrityMatches,
+ description: `${sriDescription} cors, plain load`,
+ });
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`);
+ scriptElem.crossOrigin = "anonymous";
+ scriptElem.integrity = integrity;
+ },
+ expectLoad: integrityMatches,
+ description: `${sriDescription} cors, same-origin redirect`,
+ });
+ await checkScriptLoad({
+ setupScript(scriptElem) {
+ scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`);
+ scriptElem.crossOrigin = "anonymous";
+ scriptElem.integrity = integrity;
+ },
+ expectLoad: integrityMatches,
+ description: `${sriDescription} cors, cross-origin redirect`,
+ });
+ }
+ await testSRI({ integrityMatches: true });
+ await testSRI({ integrityMatches: false });
+ });
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_csp_frame_ancestors.js b/toolkit/components/extensions/test/xpcshell/test_ext_csp_frame_ancestors.js
new file mode 100644
index 0000000000..ae931dfe06
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_csp_frame_ancestors.js
@@ -0,0 +1,221 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com", "example.net"] });
+server.registerPathHandler("/parent.html", (request, response) => {
+ let frameUrl = new URLSearchParams(request.queryString).get("iframe_src");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write(`<!DOCTYPE html><iframe src="${frameUrl}"></iframe>`);
+});
+
+// Loads an extension frame as a frame at ancestorOrigins[0], which in turn is
+// a child of ancestorOrigins[1], etc.
+// The frame should either load successfully, or trigger exactly one failure due
+// to one of the ancestorOrigins being blocked by the content_security_policy.
+async function checkExtensionLoadInFrame({
+ ancestorOrigins,
+ content_security_policy,
+ expectLoad,
+}) {
+ const extensionData = {
+ manifest: {
+ content_security_policy,
+ web_accessible_resources: ["parent.html", "frame.html"],
+ },
+ files: {
+ "frame.html": `<!DOCTYPE html><script src="frame.js"></script>`,
+ "frame.js": () => {
+ browser.test.sendMessage("frame_load_completed");
+ },
+ "parent.html": `<!DOCTYPE html><body><script src="parent.js"></script>`,
+ "parent.js": () => {
+ let iframe = document.createElement("iframe");
+ iframe.src = new URLSearchParams(location.search).get("iframe_src");
+ document.body.append(iframe);
+ },
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ const EXTENSION_FRAME_URL = `moz-extension://${extension.uuid}/frame.html`;
+
+ // ancestorOrigins is a list of origins, from the parent up to the top frame.
+ let topUrl = EXTENSION_FRAME_URL;
+ for (let origin of ancestorOrigins) {
+ if (origin === "EXTENSION_ORIGIN") {
+ origin = `moz-extension://${extension.uuid}`;
+ }
+ // origin is either the origin for |server| or the test extension. Both
+ // endpoints serve a page at parent.html that embeds iframe_src.
+ topUrl = `${origin}/parent.html?iframe_src=${encodeURIComponent(topUrl)}`;
+ }
+
+ let cspViolationObserver;
+ let cspViolationCount = 0;
+ let frameLoadedCount = 0;
+ let frameLoadOrFailedPromise = new Promise(resolve => {
+ extension.onMessage("frame_load_completed", () => {
+ ++frameLoadedCount;
+ resolve();
+ });
+ cspViolationObserver = {
+ observe(subject, topic, data) {
+ ++cspViolationCount;
+ Assert.equal(data, "frame-ancestors", "CSP violation directive");
+ resolve();
+ },
+ };
+ Services.obs.addObserver(cspViolationObserver, "csp-on-violate-policy");
+ });
+
+ const contentPage = await ExtensionTestUtils.loadContentPage(topUrl);
+
+ // Firstly, wait for the frame load to either complete or fail.
+ await frameLoadOrFailedPromise;
+
+ // Secondly, do a round trip to the content process to make sure that any
+ // unexpected extra load/failures are observed. This is necessary, because
+ // the "csp-on-violate-policy" notification is triggered from the parent,
+ // while it may be possible for the load to continue in the child anyway.
+ //
+ // And while we are at it, this verifies that the CSP does not block regular
+ // reads of a file that's part of web_accessible_resources. For comparable
+ // results, the load should ideally happen in the parent of the extension
+ // frame, but contentPage.fetch only works in the top frame, so this does not
+ // work perfectly in case ancestorOrigins.length > 1.
+ // But that is OK, as we mainly care about unexpected frame loads/failures.
+ equal(
+ await contentPage.fetch(EXTENSION_FRAME_URL),
+ extensionData.files["frame.html"],
+ "web-accessible extension resource can still be read with fetch"
+ );
+
+ // Finally, clean up.
+ Services.obs.removeObserver(cspViolationObserver, "csp-on-violate-policy");
+ await contentPage.close();
+ await extension.unload();
+
+ if (expectLoad) {
+ equal(cspViolationCount, 0, "Expected no CSP violations");
+ equal(
+ frameLoadedCount,
+ 1,
+ `Frame should accept ancestors (${ancestorOrigins}) in CSP: ${content_security_policy}`
+ );
+ } else {
+ equal(cspViolationCount, 1, "Expected CSP violation count");
+ equal(
+ frameLoadedCount,
+ 0,
+ `Frame should reject one of the ancestors (${ancestorOrigins}) in CSP: ${content_security_policy}`
+ );
+ }
+}
+
+add_task(async function test_frame_ancestors_missing_allows_self() {
+ await checkExtensionLoadInFrame({
+ ancestorOrigins: ["EXTENSION_ORIGIN"],
+ content_security_policy: "default-src 'self'", // missing frame-ancestors.
+ expectLoad: true, // an extension can embed itself by default.
+ });
+});
+
+add_task(async function test_frame_ancestors_self_allows_self() {
+ await checkExtensionLoadInFrame({
+ ancestorOrigins: ["EXTENSION_ORIGIN"],
+ content_security_policy: "default-src 'self'; frame-ancestors 'self'",
+ expectLoad: true,
+ });
+});
+
+add_task(async function test_frame_ancestors_none_blocks_self() {
+ await checkExtensionLoadInFrame({
+ ancestorOrigins: ["EXTENSION_ORIGIN"],
+ content_security_policy: "default-src 'self'; frame-ancestors",
+ expectLoad: false, // frame-ancestors 'none' blocks extension frame.
+ });
+});
+
+add_task(async function test_frame_ancestors_missing_allowed_in_web_page() {
+ await checkExtensionLoadInFrame({
+ ancestorOrigins: ["http://example.com"],
+ content_security_policy: "default-src 'self'", // missing frame-ancestors
+ expectLoad: true, // Web page can embed web-accessible extension frames.
+ });
+});
+
+add_task(async function test_frame_ancestors_self_blocked_in_web_page() {
+ await checkExtensionLoadInFrame({
+ ancestorOrigins: ["http://example.com"],
+ content_security_policy: "default-src 'self'; frame-ancestors 'self'",
+ expectLoad: false,
+ });
+});
+
+add_task(async function test_frame_ancestors_scheme_allowed_in_web_page() {
+ await checkExtensionLoadInFrame({
+ ancestorOrigins: ["http://example.com"],
+ content_security_policy: "default-src 'self'; frame-ancestors http:",
+ expectLoad: true,
+ });
+});
+
+add_task(async function test_frame_ancestors_origin_allowed_in_web_page() {
+ await checkExtensionLoadInFrame({
+ ancestorOrigins: ["http://example.com"],
+ content_security_policy:
+ "default-src 'self'; frame-ancestors http://example.com",
+ expectLoad: true,
+ });
+});
+
+add_task(async function test_frame_ancestors_mismatch_blocked_in_web_page() {
+ await checkExtensionLoadInFrame({
+ ancestorOrigins: ["http://example.com"],
+ content_security_policy:
+ "default-src 'self'; frame-ancestors http://not.example.com",
+ expectLoad: false,
+ });
+});
+
+add_task(async function test_frame_ancestors_top_mismatch_blocked() {
+ await checkExtensionLoadInFrame({
+ ancestorOrigins: ["http://example.com", "http://example.net"],
+ content_security_policy:
+ "default-src 'self'; frame-ancestors http://example.com",
+ // example.com is allowed, but the top origin (example.net) is rejected.
+ expectLoad: false,
+ });
+});
+
+add_task(async function test_frame_ancestors_parent_mismatch_blocked() {
+ await checkExtensionLoadInFrame({
+ ancestorOrigins: ["http://example.net", "http://example.com"],
+ content_security_policy:
+ "default-src 'self'; frame-ancestors http://example.com",
+ // example.com is allowed, but the parent origin (example.net) is rejected.
+ expectLoad: false,
+ });
+});
+
+add_task(async function test_frame_ancestors_middle_rejected() {
+ if (!WebExtensionPolicy.useRemoteWebExtensions) {
+ // This test load http://example.com in an extension page, which fails if
+ // extensions run in the parent process. This is not a default config on
+ // desktop, but see https://bugzilla.mozilla.org/show_bug.cgi?id=1724099
+ info("Web pages cannot be loaded in extension page without OOP extensions");
+ return;
+ }
+ await checkExtensionLoadInFrame({
+ ancestorOrigins: ["http://example.com", "EXTENSION_ORIGIN"],
+ content_security_policy:
+ "default-src 'self'; frame-src http: 'self'; frame-ancestors 'self'",
+ // Although the top frame has the same origin as the extension, the load
+ // should be rejected anyway because there is a non-allowlisted origin in
+ // the middle (child of top frame, parent of extension frame).
+ expectLoad: false,
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_csp_upgrade_requests.js b/toolkit/components/extensions/test/xpcshell/test_ext_csp_upgrade_requests.js
new file mode 100644
index 0000000000..6780293f04
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_csp_upgrade_requests.js
@@ -0,0 +1,74 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerPathHandler("/", (req, res) => {
+ res.write("ok");
+});
+
+add_setup(async () => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+});
+
+add_task(async function test_csp_upgrade() {
+ async function background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(
+ details.url,
+ "https://example.com/",
+ "request upgraded and sent"
+ );
+ browser.test.notifyPass();
+ return { cancel: true };
+ },
+ {
+ urls: ["https://example.com/*"],
+ },
+ ["blocking"]
+ );
+
+ await browser.test.assertRejects(
+ fetch("http://example.com/"),
+ "NetworkError when attempting to fetch resource.",
+ "request was upgraded"
+ );
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ temporarilyInstalled: true,
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ host_permissions: ["*://example.com/*"],
+ permissions: ["webRequest", "webRequestBlocking"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_csp_noupgrade() {
+ async function background() {
+ let req = await fetch("http://example.com/");
+ browser.test.assertEq(
+ req.url,
+ "http://example.com/",
+ "request not upgraded"
+ );
+ browser.test.notifyPass();
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ temporarilyInstalled: true,
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ host_permissions: ["*://example.com/*"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js b/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js
new file mode 100644
index 0000000000..5fc7f2ba3f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js
@@ -0,0 +1,312 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+add_task(async function testExtensionDebuggingUtilsCleanup() {
+ const extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("background.ready");
+ },
+ });
+
+ const expectedEmptyDebugUtils = {
+ hiddenXULWindow: null,
+ cacheSize: 0,
+ };
+
+ let { hiddenXULWindow, debugBrowserPromises } = ExtensionParent.DebugUtils;
+
+ deepEqual(
+ { hiddenXULWindow, cacheSize: debugBrowserPromises.size },
+ expectedEmptyDebugUtils,
+ "No ExtensionDebugUtils resources has been allocated yet"
+ );
+
+ await extension.startup();
+
+ await extension.awaitMessage("background.ready");
+
+ hiddenXULWindow = ExtensionParent.DebugUtils.hiddenXULWindow;
+ deepEqual(
+ { hiddenXULWindow, cacheSize: debugBrowserPromises.size },
+ expectedEmptyDebugUtils,
+ "No debugging resources has been yet allocated once the extension is running"
+ );
+
+ const fakeAddonActor = {
+ addonId: extension.id,
+ };
+
+ const anotherAddonActor = {
+ addonId: extension.id,
+ };
+
+ const waitFirstBrowser =
+ ExtensionParent.DebugUtils.getExtensionProcessBrowser(fakeAddonActor);
+ const waitSecondBrowser =
+ ExtensionParent.DebugUtils.getExtensionProcessBrowser(anotherAddonActor);
+
+ const addonDebugBrowser = await waitFirstBrowser;
+ equal(
+ addonDebugBrowser.isRemoteBrowser,
+ extension.extension.remote,
+ "The addon debugging browser has the expected remote type"
+ );
+
+ equal(
+ await waitSecondBrowser,
+ addonDebugBrowser,
+ "Two addon debugging actors related to the same addon get the same browser element "
+ );
+
+ equal(
+ debugBrowserPromises.size,
+ 1,
+ "The expected resources has been allocated"
+ );
+
+ const nonExistentAddonActor = {
+ addonId: "non-existent-addon@test",
+ };
+
+ const waitRejection = ExtensionParent.DebugUtils.getExtensionProcessBrowser(
+ nonExistentAddonActor
+ );
+
+ await Assert.rejects(
+ waitRejection,
+ /Extension not found/,
+ "Reject with the expected message for non existent addons"
+ );
+
+ equal(
+ debugBrowserPromises.size,
+ 1,
+ "No additional debugging resources has been allocated"
+ );
+
+ await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(
+ fakeAddonActor
+ );
+
+ equal(
+ debugBrowserPromises.size,
+ 1,
+ "The addon debugging browser is cached until all the related actors have released it"
+ );
+
+ await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(
+ anotherAddonActor
+ );
+
+ hiddenXULWindow = ExtensionParent.DebugUtils.hiddenXULWindow;
+
+ deepEqual(
+ { hiddenXULWindow, cacheSize: debugBrowserPromises.size },
+ expectedEmptyDebugUtils,
+ "All the allocated debugging resources has been cleared"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionDebuggingUtilsAddonReloaded() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "test-reloaded@test.mozilla.com",
+ },
+ },
+ },
+ background() {
+ browser.test.sendMessage("background.ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background.ready");
+
+ let fakeAddonActor = {
+ addonId: extension.id,
+ };
+
+ const addonDebugBrowser =
+ await ExtensionParent.DebugUtils.getExtensionProcessBrowser(fakeAddonActor);
+ equal(
+ addonDebugBrowser.isRemoteBrowser,
+ extension.extension.remote,
+ "The addon debugging browser has the expected remote type"
+ );
+ equal(
+ ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 1,
+ "Got the expected number of requested debug browsers"
+ );
+
+ const { chromeDocument } = ExtensionParent.DebugUtils.hiddenXULWindow;
+
+ ok(
+ addonDebugBrowser.parentElement === chromeDocument.documentElement,
+ "The addon debugging browser is part of the hiddenXULWindow chromeDocument"
+ );
+
+ await extension.unload();
+
+ // Install an extension with the same id to recreate for the DebugUtils
+ // conditions similar to an addon reloaded while the Addon Debugger is opened.
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "test-reloaded@test.mozilla.com",
+ },
+ },
+ },
+ background() {
+ browser.test.sendMessage("background.ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background.ready");
+
+ equal(
+ ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 1,
+ "Got the expected number of requested debug browsers"
+ );
+
+ const newAddonDebugBrowser =
+ await ExtensionParent.DebugUtils.getExtensionProcessBrowser(fakeAddonActor);
+
+ equal(
+ addonDebugBrowser,
+ newAddonDebugBrowser,
+ "The existent debugging browser has been reused"
+ );
+
+ equal(
+ newAddonDebugBrowser.isRemoteBrowser,
+ extension.extension.remote,
+ "The addon debugging browser has the expected remote type"
+ );
+
+ await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(
+ fakeAddonActor
+ );
+
+ equal(
+ ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 0,
+ "All the addon debugging browsers has been released"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionDebuggingUtilsWithMultipleAddons() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "test-addon-1@test.mozilla.com",
+ },
+ },
+ },
+ background() {
+ browser.test.sendMessage("background.ready");
+ },
+ });
+ let anotherExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "test-addon-2@test.mozilla.com",
+ },
+ },
+ },
+ background() {
+ browser.test.sendMessage("background.ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background.ready");
+
+ await anotherExtension.startup();
+ await anotherExtension.awaitMessage("background.ready");
+
+ const fakeAddonActor = {
+ addonId: extension.id,
+ };
+
+ const anotherFakeAddonActor = {
+ addonId: anotherExtension.id,
+ };
+
+ const { DebugUtils } = ExtensionParent;
+ const debugBrowser = await DebugUtils.getExtensionProcessBrowser(
+ fakeAddonActor
+ );
+ const anotherDebugBrowser = await DebugUtils.getExtensionProcessBrowser(
+ anotherFakeAddonActor
+ );
+
+ const chromeDocument = DebugUtils.hiddenXULWindow.chromeDocument;
+
+ equal(
+ ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 2,
+ "Got the expected number of debug browsers requested"
+ );
+ ok(
+ debugBrowser.parentElement === chromeDocument.documentElement,
+ "The first debug browser is part of the hiddenXUL chromeDocument"
+ );
+ ok(
+ anotherDebugBrowser.parentElement === chromeDocument.documentElement,
+ "The second debug browser is part of the hiddenXUL chromeDocument"
+ );
+
+ await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(
+ fakeAddonActor
+ );
+
+ equal(
+ ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 1,
+ "Got the expected number of debug browsers requested"
+ );
+
+ ok(
+ anotherDebugBrowser.parentElement === chromeDocument.documentElement,
+ "The second debug browser is still part of the hiddenXUL chromeDocument"
+ );
+
+ ok(
+ debugBrowser.parentElement == null,
+ "The first debug browser has been removed from the hiddenXUL chromeDocument"
+ );
+
+ await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(
+ anotherFakeAddonActor
+ );
+
+ ok(
+ anotherDebugBrowser.parentElement == null,
+ "The second debug browser has been removed from the hiddenXUL chromeDocument"
+ );
+ equal(
+ ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 0,
+ "All the addon debugging browsers has been released"
+ );
+
+ await extension.unload();
+ await anotherExtension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js
new file mode 100644
index 0000000000..ccb380180f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js
@@ -0,0 +1,1231 @@
+"use strict";
+
+// This file tests whether the "allowAllRequests" action is correctly applied
+// to subresource requests. The relative precedence to other actions/extensions
+// is tested in test_ext_dnr_testMatchOutcome.js, specifically by test tasks
+// rule_priority_and_action_type_precedence and
+// action_precedence_between_extensions.
+
+ChromeUtils.defineESModuleGetters(this, {
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+add_setup(() => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+});
+
+const server = createHttpServer({
+ hosts: ["example.com", "example.net", "example.org"],
+});
+server.registerPathHandler("/never_reached", (req, res) => {
+ Assert.ok(false, "Server should never have been reached");
+});
+server.registerPathHandler("/allowed", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.setHeader("Access-Control-Max-Age", "0");
+ // Any test that is able to check the response body will be able to assert
+ // the response body's value. Let's use "fetchAllowed" so that the compared
+ // values are obvious when assertEq/assertDeepEq are used.
+ res.write("fetchAllowed");
+});
+server.registerPathHandler("/", (req, res) => {
+ res.write("Dummy page");
+});
+server.registerPathHandler("/echo_html", (req, res) => {
+ let code = decodeURIComponent(req.queryString);
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ if (req.hasHeader("prependhtml")) {
+ code = req.getHeader("prependhtml") + code;
+ }
+ res.write(`<!DOCTYPE html>${code}`);
+});
+server.registerPathHandler("/bfcache_test", (req, res) => {
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.write(`<body><script>
+ // false at initial load, true when loaded from bfcache.
+ onpageshow = e => document.body.textContent = e.persisted;
+ </script>`);
+});
+
+async function waitForRequestAtServer(path) {
+ return new Promise(resolve => {
+ let callCount = 0;
+ server.registerPathHandler(path, (req, res) => {
+ Assert.equal(++callCount, 1, `Got one request for: ${path}`);
+ res.processAsync();
+ resolve({ req, res });
+ });
+ });
+}
+
+// Several tests expect fetch() to fail due to the request being blocked.
+// They can use testLoadInFrame({ ..., expectedError: FETCH_BLOCKED }).
+const FETCH_BLOCKED =
+ "TypeError: NetworkError when attempting to fetch resource.";
+
+function urlEchoHtml(domain, html) {
+ return `http://${domain}/echo_html?${encodeURIComponent(html)}`;
+}
+
+function htmlEscape(html) {
+ return html
+ .replaceAll("&", "&amp;")
+ .replaceAll('"', "&quot;")
+ .replaceAll("'", "&#39;")
+ .replaceAll("<", "&lt;")
+ .replaceAll(">", "&gt;");
+}
+
+// Values for domains in testLoadInFrame.
+const ABOUT_SRCDOC_SAME_ORIGIN = "about:srcdoc (same-origin)";
+const ABOUT_SRCDOC_CROSS_ORIGIN = "about:srcdoc (cross-origin)";
+
+async function testLoadInFrame({
+ description,
+ // domains[0] = main frame, every extra item is a child frame.
+ domains = ["example.com"],
+ htmlPrependedToEachFrame = "",
+ // jsForFrame will be serialized and run in the deepest frame.
+ jsForFrame,
+ // The expected (potentially async) return value of jsForFrame.
+ expectedResult,
+ // The expected (potentially async) error thrown from jsForFrame.
+ expectedError,
+}) {
+ const frameJs = async jsForFrame => {
+ let result = {};
+ try {
+ result.returnValue = await jsForFrame();
+ } catch (e) {
+ result.error = String(e);
+ }
+ // jsForFrame may return "delay_postMessage" to postpone the resolution of
+ // the promise. When the test is ready to resume, `top.postMessage()` can
+ // be called with the result, from any frame. This would also happen if the
+ // URL generated by this testLoadInFrame helper are re-used, e.g. by a new
+ // navigation to the URL that triggers a return value from jsForFrame that
+ // differs from "delay_postMessage".
+ if (result.returnValue !== "delay_postMessage") {
+ top.postMessage(result, "*");
+ }
+ };
+ const frameHtml = `<body><script>(${frameJs})(${jsForFrame})</script>`;
+
+ // Construct the frame tree so that domains[0] is the main frame, and
+ // domains[domains.length - 1] is the deepest level frame (if any).
+
+ const [mainFrameDomain, ...subFramesDomains] = domains;
+
+ // The loop below generates the HTML for the deepest frame first, so we have
+ // to reverse the list of domains.
+ subFramesDomains.reverse();
+
+ let html = frameHtml;
+ for (let domain of subFramesDomains) {
+ html = htmlPrependedToEachFrame + html;
+ if (domain === ABOUT_SRCDOC_SAME_ORIGIN) {
+ html = `<iframe srcdoc="${htmlEscape(html)}"></iframe>`;
+ } else if (domain === ABOUT_SRCDOC_CROSS_ORIGIN) {
+ html = `<iframe srcdoc="${htmlEscape(
+ html
+ )}" sandbox="allow-scripts"></iframe>`;
+ } else {
+ html = `<iframe src="${urlEchoHtml(domain, html)}"></iframe>`;
+ }
+ }
+
+ const mainFrameJs = () => {
+ window.resultPromise = new Promise(resolve => {
+ window.onmessage = e => resolve(e.data);
+ });
+ };
+ const mainFrameHtml = `<script>(${mainFrameJs})()</script>${html}`;
+ const mainFrameUrl = urlEchoHtml(mainFrameDomain, mainFrameHtml);
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(mainFrameUrl);
+ let result = await contentPage.spawn([], () => {
+ return content.wrappedJSObject.resultPromise;
+ });
+ await contentPage.close();
+ if (expectedError) {
+ Assert.deepEqual(result, { error: expectedError }, description);
+ } else {
+ Assert.deepEqual(result, { returnValue: expectedResult }, description);
+ }
+}
+
+async function loadExtensionWithDNRRules(
+ rules,
+ {
+ // host_permissions is only required for modifyHeaders/redirect, or when
+ // "declarativeNetRequestWithHostAccess" is used.
+ host_permissions = [],
+ permissions = ["declarativeNetRequest"],
+ } = {}
+) {
+ async function background(rules) {
+ try {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: rules,
+ });
+ } catch (e) {
+ browser.test.fail(`Failed to register DNR rules: ${e} :: ${e.stack}`);
+ }
+ browser.test.sendMessage("dnr_registered");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${background})(${JSON.stringify(rules)})`,
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ host_permissions,
+ permissions,
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("dnr_registered");
+ return extension;
+}
+
+add_task(async function allowAllRequests_allows_request() {
+ let extension = await loadExtensionWithDNRRules([
+ // allowAllRequests should take precedence over block.
+ {
+ id: 1,
+ condition: { resourceTypes: ["main_frame", "xmlhttprequest"] },
+ action: { type: "block" },
+ },
+ {
+ id: 2,
+ condition: { resourceTypes: ["main_frame"] },
+ action: { type: "allowAllRequests" },
+ },
+ {
+ id: 3,
+ priority: 2,
+ // Note: when not specified, main_frame is excluded by default. So
+ // when a main_frame request is triggered, only rules 1 and 2 match.
+ condition: { requestDomains: ["example.com"] },
+ action: { type: "block" },
+ },
+ ]);
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/"
+ );
+ Assert.equal(
+ await contentPage.spawn([], () => content.document.URL),
+ "http://example.com/",
+ "main_frame request should have been allowed by allowAllRequests"
+ );
+
+ async function checkCanFetch(url) {
+ return contentPage.spawn([url], async url => {
+ try {
+ return await (await content.fetch(url)).text();
+ } catch (e) {
+ return e.toString();
+ }
+ });
+ }
+
+ Assert.equal(
+ await checkCanFetch("http://example.com/never_reached"),
+ FETCH_BLOCKED,
+ "should be blocked by DNR rule 3"
+ );
+ Assert.equal(
+ await checkCanFetch("http://example.net/allowed"),
+ "fetchAllowed",
+ "should not be blocked by block rule due to allowAllRequests rule"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function allowAllRequests_in_sub_frame() {
+ const extension = await loadExtensionWithDNRRules([
+ {
+ id: 1,
+ condition: { resourceTypes: ["xmlhttprequest"] },
+ action: { type: "block" },
+ },
+ {
+ id: 2,
+ condition: {
+ requestDomains: ["example.com"],
+ resourceTypes: ["main_frame", "sub_frame"],
+ },
+ action: { type: "allowAllRequests" },
+ },
+ ]);
+
+ const testFetch = async () => {
+ // Should be able to read, unless blocked by DNR rule 1 above.
+ return (await fetch("http://example.com/allowed")).text();
+ };
+
+ // Sanity check: verify that the test result is different (i.e. FETCH_BLOCKED)
+ // when the "allowAllRequests" rule (rule ID 2) is not matched.
+ await testLoadInFrame({
+ description: "allowAllRequests was not matched anywhere, req in subframe",
+ domains: ["example.net", "example.org"],
+ jsForFrame: testFetch,
+ expectedError: FETCH_BLOCKED,
+ });
+
+ // allowAllRequests applied to domains[0], i.e. "main_frame".
+ await testLoadInFrame({
+ description: "allowAllRequests for main frame, req in main frame",
+ domains: ["example.com"],
+ jsForFrame: testFetch,
+ expectedResult: "fetchAllowed",
+ });
+ await testLoadInFrame({
+ description: "allowAllRequests for main frame, req in same-origin frame",
+ domains: ["example.com", "example.com"],
+ jsForFrame: testFetch,
+ expectedResult: "fetchAllowed",
+ });
+ await testLoadInFrame({
+ description: "allowAllRequests for main frame, req in cross-origin frame",
+ domains: ["example.com", "example.net"],
+ jsForFrame: testFetch,
+ expectedResult: "fetchAllowed",
+ });
+
+ // allowAllRequests applied to domains[1], i.e. "sub_frame".
+ await testLoadInFrame({
+ description: "allowAllRequests for subframe, req in same subframe",
+ domains: ["example.net", "example.com"],
+ jsForFrame: testFetch,
+ expectedResult: "fetchAllowed",
+ });
+ await testLoadInFrame({
+ description: "allowAllRequests for subframe, req in same-origin subframe",
+ domains: ["example.net", "example.com", "example.com"],
+ jsForFrame: testFetch,
+ expectedResult: "fetchAllowed",
+ });
+ await testLoadInFrame({
+ description: "allowAllRequests for subframe, req in cross-origin subframe",
+ domains: ["example.net", "example.com", "example.org"],
+ jsForFrame: testFetch,
+ expectedResult: "fetchAllowed",
+ });
+
+ await extension.unload();
+});
+
+add_task(async function allowAllRequests_does_not_affect_other_extension() {
+ const extension = await loadExtensionWithDNRRules([
+ {
+ id: 1,
+ condition: { resourceTypes: ["xmlhttprequest"] },
+ action: { type: "block" },
+ },
+ ]);
+ const otherExtension = await loadExtensionWithDNRRules([
+ {
+ id: 2,
+ condition: { resourceTypes: ["main_frame", "sub_frame"] },
+ action: { type: "allowAllRequests" },
+ },
+ ]);
+
+ const testFetch = async () => {
+ return (await fetch("http://example.com/allowed")).text();
+ };
+
+ // Sanity check: verify that the test result is different (i.e. FETCH_BLOCKED)
+ // when the "allowAllRequests" rule (rule ID 2) is not matched.
+ await testLoadInFrame({
+ description: "block rule from extension not superseded by otherExtension",
+ domains: ["example.net", "example.org"],
+ jsForFrame: testFetch,
+ expectedError: FETCH_BLOCKED,
+ });
+
+ await extension.unload();
+ await otherExtension.unload();
+});
+
+// When there are multiple frames and matching allowAllRequests, we need to
+// use the highest-priority allowAllRequests rule. The selected rule can be
+// observed through interleaved modifyHeaders rules.
+add_task(async function allowAllRequests_multiple_frames_and_modifyHeaders() {
+ const domains = ["example.com", "example.com", "example.net", "example.org"];
+ const rules = [
+ {
+ id: 1,
+ priority: 3,
+ condition: { requestDomains: [domains[1]], resourceTypes: ["sub_frame"] },
+ action: { type: "allowAllRequests" },
+ },
+ {
+ id: 2,
+ priority: 7,
+ condition: { requestDomains: [domains[2]], resourceTypes: ["sub_frame"] },
+ action: { type: "allowAllRequests" },
+ },
+ {
+ id: 3,
+ priority: 5,
+ condition: { requestDomains: [domains[3]], resourceTypes: ["sub_frame"] },
+ action: { type: "allowAllRequests" },
+ },
+ // The loop below will add modifyHeaders rules with priorities 1 - 9.
+ ];
+ for (let i = 1; i <= 9; ++i) {
+ rules.push({
+ id: 10 + i, // not overlapping with any rule in |rules|.
+ priority: i,
+ condition: { resourceTypes: ["xmlhttprequest"] },
+ action: {
+ type: "modifyHeaders",
+ responseHeaders: [
+ {
+ // Expose the header via CORS to allow fetch() to read the header.
+ operation: "set",
+ header: "Access-Control-Expose-Headers",
+ value: "addedByDnr",
+ },
+ { operation: "append", header: "addedByDnr", value: `${i}` },
+ ],
+ },
+ });
+ }
+
+ const extension = await loadExtensionWithDNRRules(rules, {
+ // host_permissions required for "modifyHeaders" action.
+ host_permissions: ["<all_urls>"],
+ });
+
+ await testLoadInFrame({
+ description: "Should select highest-prio allowAllRequests among ancestors",
+ domains,
+ jsForFrame: async () => {
+ let res = await fetch("http://example.com/allowed");
+ return res.headers.get("addedByDnr");
+ },
+ // The fetch request matches all xmlhttprequest rules, which would append
+ // the numbers 1...9 to the results via "modifyHeaders".
+ //
+ // But every frame also has one matching "allowAllRequests" rule. Among
+ // these, we should not select an arbitrary rule, but the one with the
+ // highest priority, i.e. priority 7 (matches domains[2]).
+ //
+ // Given the "allowAllRequests" of priority 7, all rules of lower-or-equal
+ // priority are ignored, so only "modifyHeaders" remain with priority 8 & 9.
+ //
+ // modifyHeaders are applied in the order of priority: "9, 8", not "8, 9".
+ expectedResult: "9, 8",
+ });
+
+ await extension.unload();
+});
+
+add_task(async function allowAllRequests_initiatorDomains() {
+ const rules = [
+ {
+ id: 1,
+ condition: {
+ initiatorDomains: ["example.com"], // Note: in host_permissions below.
+ resourceTypes: ["main_frame", "sub_frame"],
+ },
+ action: { type: "allowAllRequests" },
+ },
+ {
+ id: 2,
+ condition: {
+ initiatorDomains: ["example.net"], // Note: NOT in host_permissions.
+ resourceTypes: ["sub_frame"],
+ },
+ action: { type: "allowAllRequests" },
+ },
+ {
+ id: 3,
+ condition: { resourceTypes: ["xmlhttprequest"] },
+ action: { type: "block" },
+ },
+ ];
+
+ const extension = await loadExtensionWithDNRRules(rules, {
+ // host_permissions matches initiatorDomains from rule 1 (allowAllRequests)
+ // and the origin of the frame that calls testCanFetch.
+ host_permissions: ["*://example.com/*", "*://example.org/*"],
+ });
+
+ const testCanFetch = async () => {
+ return (await fetch("http://example.com/allowed")).text();
+ };
+
+ await testLoadInFrame({
+ description: "main_frame request does not have an initiator",
+ domains: ["example.com"],
+ jsForFrame: testCanFetch,
+ // Rule 1 (initiatorDomains: ["example.com"]) should not match.
+ expectedError: FETCH_BLOCKED,
+ });
+ await testLoadInFrame({
+ description: "sub_frame loaded by initiator in host_permissions",
+ domains: ["example.com", "example.org"],
+ jsForFrame: testCanFetch,
+ // Matched by rule 1 (initiatorDomains: ["example.com"])
+ expectedResult: "fetchAllowed",
+ });
+ await testLoadInFrame({
+ description: "sub_frame loaded by initiator not in host_permissions",
+ domains: ["example.net", "example.org"],
+ jsForFrame: testCanFetch,
+ // Matched by rule 2 (initiatorDomains: ["example.net"]). While example.net
+ // is not in host_permissions, the "allowAllRequests" rule can apply because
+ // the extension does have the "declarativeNetRequest" permission (opposed
+ // to just "declarativeNetRequestWithHostAccess", which is covered by the
+ // allowAllRequests_initiatorDomains_dnrWithHostAccess test task below).
+ expectedResult: "fetchAllowed",
+ });
+
+ // about:srcdoc inherits parent origin.
+ await testLoadInFrame({
+ description: "about:srcdoc with matching initiator",
+ domains: ["example.com", ABOUT_SRCDOC_SAME_ORIGIN],
+ jsForFrame: testCanFetch,
+ // While the "about:srcdoc" frame's initiator is matched by rule 1
+ // (initiatorDomains: ["example.com"]), the frame's URL itself is
+ // "about:srcdoc" and consequently ignored in the matcher.
+ expectedError: FETCH_BLOCKED,
+ });
+ await testLoadInFrame({
+ description: "subframe in about:srcdoc with matching initiator",
+ domains: ["example.com", ABOUT_SRCDOC_SAME_ORIGIN, "example.org"],
+ jsForFrame: testCanFetch,
+ // The parent URL is "about:srcdoc", but its principal is inherit from its
+ // parent, i.e. "example.com". Therefore it matches rule 1.
+ expectedResult: "fetchAllowed",
+ });
+ await testLoadInFrame({
+ description: "subframe in opaque about:srcdoc despite matching initiator",
+ domains: ["example.com", ABOUT_SRCDOC_CROSS_ORIGIN, "example.org"],
+ jsForFrame: testCanFetch,
+ // The parent URL is "about:srcdoc". Because it is sandboxed, it has an
+ // opaque origin and therefore none of the allowAllRequests rules match,
+ // even not rule 1 even though the "about:srcdoc" frame was created by
+ // "example.com".
+ expectedError: FETCH_BLOCKED,
+ });
+
+ await extension.unload();
+});
+
+add_task(async function allowAllRequests_initiatorDomains_dnrWithHostAccess() {
+ const rules = [
+ {
+ id: 1,
+ condition: {
+ // This test shows that it does not matter whether initiatorDomains is
+ // in host_permissions; it only matters if the frame's URL is matched
+ // by host_permissions.
+ initiatorDomains: ["example.net"], // Not in host_permissions.
+ resourceTypes: ["sub_frame"],
+ },
+ action: { type: "allowAllRequests" },
+ },
+ {
+ id: 2,
+ condition: { resourceTypes: ["xmlhttprequest"] },
+ action: { type: "block" },
+ },
+ ];
+
+ const extension = await loadExtensionWithDNRRules(rules, {
+ host_permissions: ["*://example.org/*"],
+ permissions: ["declarativeNetRequestWithHostAccess"],
+ });
+
+ const testCanFetch = async () => {
+ // example.org is in host_permissions above so "xmlhttprequest" rule is
+ // always expected to match this, unless "allowAllRequests" applied.
+ // If "allowAllRequests" applies, then expectedResult: "fetchAllowed".
+ // If "allowAllRequests" did not apply, then expectedError: FETCH_BLOCKED.
+ return (await fetch("http://example.org/allowed")).text();
+ };
+
+ await testLoadInFrame({
+ description:
+ "frame URL in host_permissions despite initiator not in host_permissions",
+ domains: ["example.com", "example.net", "example.org"],
+ jsForFrame: testCanFetch,
+ // The "xmlhttprequest" block rule applies because the request URL
+ // (example.org) and initiator (example.org) are part of host_permissions.
+ //
+ // The "allowAllRequests" rule applies and overrides the block because the
+ // "example.org" frame has "example.net" as initiator (as specified in the
+ // initiatorDomains DNR rule). Despite the lack of host_permissions for
+ // "example.net", the DNR rule is matched because navigation requests do
+ // not require host permissions.
+ expectedResult: "fetchAllowed",
+ });
+
+ await testLoadInFrame({
+ description: "frame URL and initiator not in host_permissions",
+ domains: ["example.net", "example.com", "example.org"],
+ jsForFrame: testCanFetch,
+ // The "xmlhttprequest" block rule applies because the request URL
+ // (example.org) and initiator (example.org) are part of host_permissions.
+ //
+ // The "allowAllRequests" rule does not apply because it would only apply
+ // to the "example.com" frame (that frame has "example.net" as initiator),
+ // but the DNR extension does not have host permissions for example.com.
+ expectedError: FETCH_BLOCKED,
+ });
+
+ await extension.unload();
+});
+
+add_task(async function allowAllRequests_initiator_is_parent() {
+ // The actual initiator of a request is the principal (origin) that triggered
+ // the request. Navigations of subframes are usually triggered by the parent,
+ // except in case of cross-frame/window navigations.
+ //
+ // There are some limits on cross-frame navigations, specified by:
+ // https://html.spec.whatwg.org/multipage/browsing-the-web.html#allowed-to-navigate
+ // An ancestor can always navigate a descendant, so we do that here.
+ //
+ // - example.com (main frame)
+ // - example.net (sub frame 1)
+ // - example.org (sub frame 2)
+ // - example.com (sub frame 3) - will be navigated by sub frame 1.
+ //
+ // "initiatorDomains" is usually matched against the actual initiator of a
+ // request. Since the actual initiator (triggering principal) is not always
+ // known nor obvious, the parent principal (origin) is used instead, when the
+ // conditions for "allowAllRequests" are retroactively checked for a document.
+ const domains = ["example.com", "example.net", "example.org", "example.com"];
+ const rules = [
+ {
+ id: 1,
+ condition: {
+ // Note: restrict to example.org, so that we can verify that the
+ // "allowAllRequests" rule applies to subresource requests within any
+ // child frame of "example.org" (i.e. that rule 3 is ignored).
+ //
+ // Side note: the ultimate navigation request for the child frame
+ // itself has actual initiator "example.net" and does not match this
+ // rule, which we verify by confirming that rule 2 matches.
+ initiatorDomains: ["example.org"],
+ requestDomains: ["example.com"],
+ resourceTypes: ["sub_frame"],
+ },
+ action: { type: "allowAllRequests" },
+ },
+ {
+ id: 2,
+ condition: { resourceTypes: ["xmlhttprequest"] },
+ action: { type: "block" },
+ },
+ // The modifyHeaders rules below are not affected by the "allowAllRequests"
+ // rule above, but are part of the test to serve as a sanity check that the
+ // "initiatorDomains" field of sub_frame navigations are compared against
+ // the actual initiator.
+ {
+ id: 3,
+ priority: 2, // To not be ignored by allowAllRequests (rule 1).
+ condition: {
+ // The initial sub_frame navigation request is initiated by its parent,
+ // i.e. example.org.
+ initiatorDomains: ["example.org"],
+ requestDomains: ["example.com"],
+ resourceTypes: ["sub_frame"],
+ },
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [
+ {
+ operation: "append",
+ header: "prependhtml",
+ value: "<title>DNR rule 3 for initiator example.org</title>",
+ },
+ ],
+ },
+ },
+ {
+ id: 4,
+ condition: {
+ // The final sub_frame navigation request is initiated by a frame other
+ // than the parent (i.e. example.net).
+ initiatorDomains: ["example.net"],
+ requestDomains: ["example.com"],
+ resourceTypes: ["sub_frame"],
+ },
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [
+ {
+ operation: "append",
+ header: "prependhtml",
+ value: "<title>DNR rule 4 for initiator example.net</title>",
+ },
+ ],
+ },
+ },
+ ];
+
+ const extension = await loadExtensionWithDNRRules(rules, {
+ // host_permissions needed for allowAllRequests of ancestors
+ // (initiatorDomains & requestDomains) and modifyHeaders.
+ host_permissions: ["<all_urls>"],
+ });
+
+ const jsNavigateOnMessage = () => {
+ window.onmessage = e => {
+ dump(`\nReceived message at ${origin} from ${e.origin}: ${e.data}\n`);
+ e.source.location = e.data;
+ };
+ };
+ const htmlNavigateOnMessage = `<script>(${jsNavigateOnMessage})()</script>`;
+
+ // First: sanity check that the actual initiators are as expected, which we
+ // verify through the modifyHeaders+initiatorDomains rules, observed through
+ // document.title (/echo_html prepends the "prependhtml" header's value).
+ await testLoadInFrame({
+ description: "Sanity check: navigation matches actual initiator (parent)",
+ domains,
+ jsForFrame: () => document.title,
+ expectedResult: "DNR rule 3 for initiator example.org",
+ });
+
+ await testLoadInFrame({
+ description: "Sanity check: navigation matches actual initiator (ancestor)",
+ domains,
+ htmlPrependedToEachFrame: htmlNavigateOnMessage,
+ jsForFrame: () => {
+ if (location.hash !== "#End") {
+ dump("Sanity: Trying to navigate with initiator set to example.net\n");
+ parent.parent.postMessage(document.URL + ".#End", "http://example.net");
+ return "delay_postMessage";
+ }
+ return document.title;
+ },
+ expectedResult: "DNR rule 4 for initiator example.net",
+ });
+
+ // Now the actual test: when fetch() is called, "allowAllRequests" should use
+ // the parent origin for each frame in the frame tree.
+
+ await testLoadInFrame({
+ description: "allowAllRequests matches parent (which is the initiator)",
+ domains,
+ jsForFrame: async () => {
+ return (await fetch("http://example.com/allowed")).text();
+ },
+ expectedResult: "fetchAllowed", // fetch() not blocked, rule 1 > rule 2.
+ });
+
+ // This is where the result differs from what one may expect from
+ // "initiatorDomains". This is consistent with Chrome's behavior,
+ // https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/api/declarative_net_request/request_params.cc;l=123-130;drc=8a27797c643fb0f2d9ae835f8d8b509e027c97e9
+ await testLoadInFrame({
+ description: "allowAllRequests matches parent (not actual initiator)",
+ domains,
+ htmlPrependedToEachFrame: htmlNavigateOnMessage,
+ jsForFrame: async () => {
+ if (location.hash !== "#End") {
+ dump("Final: Trying to navigate with initiator set to example.net\n");
+ parent.parent.postMessage(document.URL + ".#End", "http://example.net");
+ return "delay_postMessage";
+ }
+ return (await fetch("http://example.com/allowed")).text();
+ },
+ expectedResult: "fetchAllowed", // fetch() not blocked, rule 1 > rule 2.
+ });
+
+ await extension.unload();
+});
+
+// Tests how initiatorDomains applies to document and non-document (fetch)
+// requests triggered from content scripts.
+add_task(async function allowAllRequests_initiatorDomains_content_script() {
+ const rules = [
+ {
+ id: 1,
+ condition: {
+ initiatorDomains: ["example.com"],
+ resourceTypes: ["sub_frame"],
+ },
+ action: { type: "allowAllRequests" },
+ },
+ {
+ id: 2,
+ condition: { resourceTypes: ["xmlhttprequest"] },
+ action: { type: "block" },
+ },
+ {
+ id: 3,
+ condition: {
+ resourceTypes: ["sub_frame"],
+ requestDomains: ["example.com"],
+ },
+ action: {
+ type: "redirect",
+ redirect: { transform: { host: "example.net" } },
+ },
+ },
+ ];
+
+ const extension = await loadExtensionWithDNRRules(rules, {
+ host_permissions: ["*://example.com/*", "*://example.net/*"],
+ });
+
+ let contentScriptExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ // Intentionally MV2 because its fetch() is tied to the content script
+ // sandbox, and thus potentially more likely to trigger bugs than the MV3
+ // fetch (fetch in MV3 is the same as the web page due to bug 1578405).
+ manifest_version: 2,
+ content_scripts: [
+ {
+ run_at: "document_end",
+ js: ["contentscript_load_frame.js"],
+ matches: ["http://*/?test_contentscript_load_frame"],
+ },
+ {
+ all_frames: true,
+ run_at: "document_end",
+ js: ["contentscript_in_iframe.js"],
+ matches: ["http://example.net/?test_contentscript_triggered_frame"],
+ },
+ ],
+ },
+ files: {
+ "contentscript_load_frame.js": () => {
+ browser.test.log("Waiting for frame, then contentscript_in_iframe.js");
+ // Created by content script; initiatorDomains should match the page's
+ // domain (and not somehow be confused by the content script principal).
+ // let document = window.document.wrappedJSObject;
+ let f = document.createElement("iframe");
+ f.src = "http://example.com/?test_contentscript_triggered_frame";
+ document.body.append(f);
+ },
+ "contentscript_in_iframe.js": async () => {
+ // When the iframe request was generated by the content script, its
+ // initiator is void because the content script has an ExpandedPrincipal
+ // that is treated as void when the request initiator is computed:
+ // https://searchfox.org/mozilla-central/rev/d85572c1963f72e8bef2787d900e0a8ffd8e6728/toolkit/components/extensions/webrequest/ChannelWrapper.cpp#551
+ // Therefore the initiatorDomains condition of rule 1 (allowAllRequests)
+ // does not match, so rule 3 (redirect to example.net) applies.
+ browser.test.assertEq(
+ "example.net", // instead of the pre-redirect URL (example.com).
+ location.host,
+ "redirect rule matched because initiator is void for content-script-triggered navigation"
+ );
+ async function isFetchOk(fetchPromise) {
+ try {
+ await fetchPromise;
+ return true; // allowAllRequests matched.
+ } catch (e) {
+ await browser.test.assertRejects(fetchPromise, /NetworkError/);
+ return false; // block rule matched because allowAllRequests didn't.
+ }
+ }
+ browser.test.assertTrue(
+ await isFetchOk(content.fetch("http://example.net/allowed")),
+ "frame's parent origin matches initiatorDomains (content script fetch)"
+ );
+ // fetch() in MV2 content script is associated with the content script
+ // sandbox, not the frame, so there are no allowAllRequests rules to
+ // apply. For equivalent request details, see bug 1444729.
+ browser.test.assertFalse(
+ await isFetchOk(fetch("http://example.net/allowed")),
+ "MV2 content script fetch() is not associated with the document"
+ );
+ browser.test.sendMessage("contentscript_initiator");
+ },
+ },
+ });
+ await contentScriptExtension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/?test_contentscript_load_frame"
+ );
+ info("Waiting for page load, will continue at contentscript_load_frame.js");
+ await contentScriptExtension.awaitMessage("contentscript_initiator");
+ await contentScriptExtension.unload();
+ await contentPage.close();
+ await extension.unload();
+});
+
+// Verifies that allowAllRequests is evaluated against the currently committed
+// document, even if another document load has been initiated.
+add_task(async function allowAllRequests_during_and_after_navigation() {
+ let extension = await loadExtensionWithDNRRules([
+ {
+ id: 1,
+ condition: { resourceTypes: ["xmlhttprequest"] },
+ action: { type: "block" },
+ },
+ {
+ id: 2,
+ condition: { urlFilter: "WITH_AAR", resourceTypes: ["sub_frame"] },
+ action: { type: "allowAllRequests" },
+ },
+ ]);
+
+ const contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/?dummy_see_iframe_for_interesting_stuff"
+ );
+ await contentPage.spawn([], async () => {
+ let f = content.document.createElement("iframe");
+ f.id = "frame_to_navigate";
+ f.src = "/?init_WITH_AAR"; // allowAllRequests initially applies.
+ await new Promise(resolve => {
+ f.onload = resolve;
+ content.document.body.append(f);
+ });
+ });
+ async function navigateIframe(url) {
+ await contentPage.spawn([url], url => {
+ let f = content.document.getElementById("frame_to_navigate");
+ content.frameLoadedPromise = new Promise(resolve => {
+ f.addEventListener("load", resolve, { once: true });
+ });
+ f.contentWindow.location.href = url;
+ });
+ }
+ async function waitForNavigationCompleted(expectLoad = true) {
+ await contentPage.spawn([expectLoad], async expectLoad => {
+ if (expectLoad) {
+ info("Waiting for frame load - if stuck the load never happened\n");
+ return content.frameLoadedPromise.then(() => {});
+ }
+ // When HTTP 204 No Content is used, onload is not fired.
+ // Here we load another frame, and assume that once this completes, that
+ // any previous load of navigateIframe() would have completed by now.
+ let f = content.document.createElement("iframe");
+ f.src = "/?dummy_no_dnr_matched_" + Math.random();
+ await new Promise(resolve => {
+ f.onload = resolve;
+ content.document.body.append(f);
+ });
+ f.remove();
+ });
+ }
+ async function assertIframePath(expectedPath, description) {
+ let actualPath = await contentPage.spawn([], () => {
+ return content.frames[0].location.pathname;
+ });
+ Assert.equal(actualPath, expectedPath, description);
+ }
+ async function assertHasAAR(expected, description) {
+ let actual = await contentPage.spawn([], async () => {
+ try {
+ await (await content.frames[0].fetch("/allowed")).text();
+ return true; // allowAllRequests overrides block rule.
+ } catch (e) {
+ // Sanity check: NetworkError from fetch(), not a random other error.
+ Assert.equal(
+ e.toString(),
+ "TypeError: NetworkError when attempting to fetch resource.",
+ "Got error for failed fetch"
+ );
+ return false; // blocked by xmlhttprequest block rule.
+ }
+ });
+ Assert.equal(actual, expected, description);
+ }
+ await assertHasAAR(true, "Initial allowAllRequests overrides block rule");
+
+ const PATH_1_NO_AAR = "/delayed/PATH_1_NO_AAR";
+ const PATH_2_WITH_AAR = "/delayed/PATH_2_WITH_AAR";
+ const PATH_3_NO_AAR = "/delayed/PATH_3_NO_AAR";
+ info("First: transition from /?init_WITH_AAR to PATH_NOT_MATCHED_BY_DNR.");
+ {
+ let promisedServerReq = waitForRequestAtServer(PATH_1_NO_AAR);
+ await navigateIframe(PATH_1_NO_AAR);
+ let serverReq = await promisedServerReq;
+ await assertHasAAR(
+ true,
+ "Initial allowAllRequests still applies despite pending navigation"
+ );
+ await assertIframePath("/", "Frame has not navigated yet");
+ serverReq.res.finish();
+ await waitForNavigationCompleted();
+ await assertIframePath(PATH_1_NO_AAR, "Navigated to PATH_1_NO_AAR");
+
+ await assertHasAAR(
+ false,
+ "Old allowAllRequests should no longer apply after navigation to PATH_1_NO_AAR"
+ );
+ }
+
+ info("Second: transition from PATH_1_NO_AAR to PATH_2_WITH_AAR.");
+ {
+ let promisedServerReq = waitForRequestAtServer(PATH_2_WITH_AAR);
+ await navigateIframe(PATH_2_WITH_AAR);
+ let serverReq = await promisedServerReq;
+ await assertHasAAR(
+ false,
+ "No allowAllRequests yet despite pending navigation to PATH_2_WITH_AAR"
+ );
+ await assertIframePath(PATH_1_NO_AAR, "Frame has not navigated yet");
+ serverReq.res.finish();
+ await waitForNavigationCompleted();
+ await assertIframePath(PATH_2_WITH_AAR, "Navigated to PATH_2_WITH_AAR");
+
+ await assertHasAAR(
+ true,
+ "allowAllRequests should apply after navigation to PATH_2_WITH_AAR"
+ );
+ }
+
+ info("Third: AAR still applies after canceling navigation to PATH_3_NO_AAR.");
+ {
+ let promisedServerReq = waitForRequestAtServer(PATH_3_NO_AAR);
+ await navigateIframe(PATH_3_NO_AAR);
+ let serverReq = await promisedServerReq;
+ serverReq.res.setStatusLine(serverReq.req.httpVersion, 204, "No Content");
+ serverReq.res.finish();
+ await waitForNavigationCompleted(/* expectLoad */ false);
+ await assertIframePath(PATH_2_WITH_AAR, "HTTP 204 does not navigate away");
+
+ await assertHasAAR(
+ true,
+ "allowAllRequests still applied after aborted navigation to PATH_3_NO_AAR"
+ );
+ }
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(
+ {
+ // Ensure that there is room for at least 2 non-evicted bfcache entries.
+ // Note: this pref is ignored (i.e forced 0) when configured (non-default)
+ // with bfcacheInParent=false while SHIP is enabled:
+ // https://searchfox.org/mozilla-central/rev/00ea1649b59d5f427979e2d6ba42be96f62d6e82/docshell/shistory/nsSHistory.cpp#360-363
+ // ... we mainly care about the bfcache here because it triggers interesting
+ // behavior. DNR evaluation is correct regardless of bfcache.
+ pref_set: [["browser.sessionhistory.max_total_viewers", 3]],
+ },
+ async function allowAllRequests_and_bfcache_navigation() {
+ let extension = await loadExtensionWithDNRRules([
+ {
+ id: 1,
+ condition: { resourceTypes: ["xmlhttprequest"] },
+ action: { type: "block" },
+ },
+ {
+ id: 2,
+ condition: { urlFilter: "aar_yes", resourceTypes: ["main_frame"] },
+ action: { type: "allowAllRequests" },
+ },
+ ]);
+
+ info("Navigating to initial URL: 1_aar_no");
+ const contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/bfcache_test?1_aar_no"
+ );
+ async function navigateBackInHistory(expectedUrl) {
+ await contentPage.spawn([], () => {
+ content.history.back();
+ });
+ await TestUtils.waitForCondition(
+ () => contentPage.browsingContext.currentURI.spec === expectedUrl,
+ `Waiting for history.back() to trigger navigation to ${expectedUrl}`
+ );
+ await contentPage.spawn([expectedUrl], async expectedUrl => {
+ Assert.equal(content.location.href, expectedUrl, "URL after back");
+ Assert.equal(content.document.body.textContent, "true", "from bfcache");
+ });
+ }
+ async function checkCanFetch(url) {
+ return contentPage.spawn([url], async url => {
+ try {
+ return await (await content.fetch(url)).text();
+ } catch (e) {
+ return e.toString();
+ }
+ });
+ }
+
+ info("Navigating from initial URL to: 2_aar_yes");
+ await contentPage.loadURL("http://example.com/bfcache_test?2_aar_yes");
+ info("Navigating from 2_aar_yes to: 3_aar_no");
+ await contentPage.loadURL("http://example.com/bfcache_test?3_aar_no");
+
+ info("Going back in history (from 3_aar_no to 2_aar_yes)");
+ await navigateBackInHistory("http://example.com/bfcache_test?2_aar_yes");
+ Assert.equal(
+ await checkCanFetch("http://example.com/allowed"),
+ "fetchAllowed",
+ "after history.back(), allowAllRequests should apply from 2_aar_yes"
+ );
+
+ info("Going back in history (from 2_aar_yes to 1_aar_no)");
+ await navigateBackInHistory("http://example.com/bfcache_test?1_aar_no");
+ Assert.equal(
+ await checkCanFetch("http://example.net/never_reached"),
+ FETCH_BLOCKED,
+ "after history.back(), no allowAllRequests action applied at 1_aar_no"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ // Usually, back/forward navigation to a POST form requires the user to
+ // confirm the form resubmission. Set pref to approve without prompting.
+ pref_set: [["dom.confirm_repost.testing.always_accept", true]],
+ },
+ async function allowAllRequests_navigate_with_http_method_POST() {
+ const rules = [
+ {
+ id: 1,
+ condition: {
+ requestMethods: ["post"],
+ resourceTypes: ["main_frame", "sub_frame"],
+ },
+ action: { type: "allowAllRequests" },
+ },
+ {
+ id: 2,
+ condition: { resourceTypes: ["xmlhttprequest"] },
+ action: { type: "block" },
+ },
+ ];
+
+ if (!Services.appinfo.sessionHistoryInParent) {
+ // POST detection relies on SHIP being enabled. This is true by default,
+ // but there are some test configurations with SHIP disabled. When SHIP
+ // is disabled, all methods are interpreted as GET instead of POST.
+ // Rewrite the rule to specifically match the POST requests that are
+ // misinterpreted as GET, to verify that the request evaluation by DNR is
+ // functional (opposed to throwing errors).
+ rules[0].condition.requestMethods = ["get"];
+ rules[0].condition.urlFilter = "do_post|";
+ info(`WARNING: SHIP is disabled. POST will be misinterpreted as GET`);
+ }
+
+ const extension = await loadExtensionWithDNRRules(rules);
+
+ const contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/?do_get"
+ );
+ async function checkCanFetch(url) {
+ return contentPage.spawn([url], async url => {
+ try {
+ return await (await content.fetch(url)).text();
+ } catch (e) {
+ return e.toString();
+ }
+ });
+ }
+
+ // Check fetch() with regular GET navigation in main_frame.
+ Assert.equal(
+ await checkCanFetch("http://example.net/never_reached"),
+ FETCH_BLOCKED,
+ "main_frame: non-POST not matched by requestMethods:['post']"
+ );
+
+ // Check fetch() after POST navigation in main_frame.
+ await contentPage.spawn([], () => {
+ let form = content.document.createElement("form");
+ form.action = "/?do_post";
+ form.method = "POST";
+ content.document.body.append(form);
+ form.submit();
+ });
+ await TestUtils.waitForCondition(
+ () => contentPage.browsingContext.currentURI.pathQueryRef === "/?do_post",
+ "Waiting for navigation with POST to complete"
+ );
+ Assert.equal(
+ await checkCanFetch("http://example.net/allowed"),
+ "fetchAllowed",
+ "main_frame: requestMethods:['post'] applies to POST"
+ );
+
+ // Navigate back to the beginning and verify that allowAllRequests does not
+ // match any more.
+ await contentPage.spawn([], () => {
+ content.history.back();
+ });
+ await TestUtils.waitForCondition(
+ () => contentPage.browsingContext.currentURI.pathQueryRef === "/?do_get",
+ "Waiting for (back) navigation to initial GET page to complete"
+ );
+ Assert.equal(
+ await checkCanFetch("http://example.net/never_reached"),
+ FETCH_BLOCKED,
+ "main_frame: back to non-POST not matched by requestMethods:['post']"
+ );
+
+ // Now navigate forwards to verify that the POST method is still seen.
+ await contentPage.spawn([], () => {
+ content.history.forward();
+ });
+ await TestUtils.waitForCondition(
+ () => contentPage.browsingContext.currentURI.pathQueryRef === "/?do_post",
+ "Waiting for (forward) navigation to POST page to complete"
+ );
+
+ Assert.equal(
+ await checkCanFetch("http://example.net/allowed"),
+ "fetchAllowed",
+ "main_frame: requestMethods:['post'] detects POST after history.forward()"
+ );
+
+ // Now check that adding a new history entry drops the POST method.
+ await contentPage.spawn([], () => {
+ content.history.pushState(null, null, "/?hist_p");
+ });
+ await TestUtils.waitForCondition(
+ () => contentPage.browsingContext.currentURI.pathQueryRef === "/?hist_p",
+ "Waiting for history.pushState to have changed the URL"
+ );
+ Assert.equal(
+ await checkCanFetch("http://example.net/never_reached"),
+ FETCH_BLOCKED,
+ "history.pushState drops POST, not matched by requestMethods:['post']"
+ );
+
+ await contentPage.close();
+
+ // Finally, check that POST detection also works for child frames.
+ await testLoadInFrame({
+ description: "sub_frame: non-POST not matched by requestMethods:['post']",
+ domains: ["example.com", "example.com"],
+ jsForFrame: async () => {
+ return (await fetch("http://example.com/allowed")).text();
+ },
+ expectedError: FETCH_BLOCKED,
+ });
+
+ await testLoadInFrame({
+ description: "sub_frame: requestMethods:['post'] applies to POST",
+ domains: ["example.com", "example.com"],
+ jsForFrame: async () => {
+ if (!location.href.endsWith("?do_post")) {
+ dump("Triggering navigation with POST\n");
+ let form = document.createElement("form");
+ form.action = location.href + "?do_post";
+ form.method = "POST";
+ document.body.append(form);
+ form.submit();
+ return "delay_postMessage";
+ }
+ dump("Navigation with POST completed; testing fetch()...\n");
+ return (await fetch("http://example.com/allowed")).text();
+ },
+ expectedResult: "fetchAllowed",
+ });
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_api.js
new file mode 100644
index 0000000000..0fc92dcb94
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_api.js
@@ -0,0 +1,383 @@
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+
+const PREF_DNR_FEEDBACK_DEFAULT_VALUE = Services.prefs.getBoolPref(
+ "extensions.dnr.feedback",
+ false
+);
+
+// To distinguish from testMatchOutcome, undefined vs resolving vs rejecting.
+const kTestMatchOutcomeNotAllowed = "kTestMatchOutcomeNotAllowed";
+
+async function testAvailability({
+ allowDNRFeedback = false,
+ testExpectations,
+ ...extensionData
+}) {
+ async function background(testExpectations) {
+ let {
+ // declarativeNetRequest should be available if "declarativeNetRequest" or
+ // "declarativeNetRequestWithHostAccess" permission is requested.
+ // (and always unavailable when "extensions.dnr.enabled" pref is false)
+ declarativeNetRequest_available = false,
+ // testMatchOutcome is available when the "declarativeNetRequestFeedback"
+ // permission is granted AND the "extensions.dnr.feedback" pref is true.
+ // (and always unavailable when "extensions.dnr.enabled" pref is false)
+ // testMatchOutcome_available: true - permission granted + pref true.
+ // testMatchOutcome_available: false - no permission, pref doesn't matter.
+ // testMatchOutcome_available: kTestMatchOutcomeNotAllowed - permission
+ // granted, but pref is false.
+ testMatchOutcome_available = false,
+ } = testExpectations;
+ browser.test.assertEq(
+ declarativeNetRequest_available,
+ !!browser.declarativeNetRequest,
+ "declarativeNetRequest API namespace availability"
+ );
+
+ // Dummy param for testMatchOutcome:
+ const dummyRequest = { url: "https://example.com/", type: "other" };
+
+ if (!testMatchOutcome_available) {
+ browser.test.assertEq(
+ undefined,
+ browser.declarativeNetRequest?.testMatchOutcome,
+ "declarativeNetRequest.testMatchOutcome availability"
+ );
+ } else if (testMatchOutcome_available === "kTestMatchOutcomeNotAllowed") {
+ await browser.test.assertRejects(
+ browser.declarativeNetRequest.testMatchOutcome(dummyRequest),
+ `declarativeNetRequest.testMatchOutcome is only available when the "extensions.dnr.feedback" preference is set to true.`,
+ "declarativeNetRequest.testMatchOutcome is unavailable"
+ );
+ } else {
+ browser.test.assertDeepEq(
+ { matchedRules: [] },
+ await browser.declarativeNetRequest.testMatchOutcome(dummyRequest),
+ "declarativeNetRequest.testMatchOutcome is available"
+ );
+ }
+ browser.test.sendMessage("done");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ ...extensionData,
+ manifest: {
+ manifest_version: 3,
+ ...extensionData.manifest,
+ },
+ background: `(${background})(${JSON.stringify(testExpectations)});`,
+ });
+ Services.prefs.setBoolPref("extensions.dnr.feedback", allowDNRFeedback);
+ try {
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ } finally {
+ Services.prefs.clearUserPref("extensions.dnr.feedback");
+ }
+}
+
+add_setup(async () => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+ // test_optional_declarativeNetRequestFeedback calls permission.request().
+ // We don't care about the UI, only about the effect of being granted.
+ Services.prefs.setBoolPref(
+ "extensions.webextOptionalPermissionPrompts",
+ false
+ );
+});
+
+add_task(
+ {
+ pref_set: [["extensions.dnr.enabled", false]],
+ },
+ async function extensions_dnr_enabled_pref_disabled_dnr_feature() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await testAvailability({
+ allowDNRFeedback: PREF_DNR_FEEDBACK_DEFAULT_VALUE,
+ testExpectations: {
+ declarativeNetRequest_available: false,
+ },
+ manifest: {
+ permissions: [
+ "declarativeNetRequest",
+ "declarativeNetRequestFeedback",
+ "declarativeNetRequestWithHostAccess",
+ ],
+ },
+ });
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ {
+ message:
+ /Reading manifest: Invalid extension permission: declarativeNetRequest$/,
+ },
+ {
+ message:
+ /Reading manifest: Invalid extension permission: declarativeNetRequestFeedback/,
+ },
+ {
+ message:
+ /Reading manifest: Invalid extension permission: declarativeNetRequestWithHostAccess/,
+ },
+ ],
+ });
+ }
+);
+
+add_task(async function dnr_feedback_apis_disabled_by_default() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await testAvailability({
+ allowDNRFeedback: PREF_DNR_FEEDBACK_DEFAULT_VALUE,
+ testExpectations: {
+ declarativeNetRequest_available: true,
+ testMatchOutcome_available: kTestMatchOutcomeNotAllowed,
+ },
+ manifest: {
+ permissions: [
+ "declarativeNetRequest",
+ "declarativeNetRequestFeedback",
+ "declarativeNetRequestWithHostAccess",
+ ],
+ },
+ });
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ forbidden: [
+ {
+ message:
+ /Reading manifest: Invalid extension permission: declarativeNetRequest$/,
+ },
+ {
+ message:
+ /Reading manifest: Invalid extension permission: declarativeNetRequestFeedback/,
+ },
+ {
+ message:
+ /Reading manifest: Invalid extension permission: declarativeNetRequestWithHostAccess/,
+ },
+ ],
+ });
+});
+
+add_task(async function dnr_available_in_mv2() {
+ await testAvailability({
+ allowDNRFeedback: true,
+ testExpectations: {
+ declarativeNetRequest_available: true,
+ testMatchOutcome_available: true,
+ },
+ manifest: {
+ manifest_version: 2,
+ permissions: [
+ "declarativeNetRequest",
+ "declarativeNetRequestFeedback",
+ "declarativeNetRequestWithHostAccess",
+ ],
+ },
+ });
+});
+
+add_task(async function with_declarativeNetRequest_permission() {
+ await testAvailability({
+ allowDNRFeedback: true,
+ testExpectations: {
+ declarativeNetRequest_available: true,
+ // feature allowed, but missing declarativeNetRequestFeedback:
+ testMatchOutcome_available: false,
+ },
+ manifest: {
+ permissions: ["declarativeNetRequest"],
+ },
+ });
+});
+
+add_task(async function with_declarativeNetRequestWithHostAccess_permission() {
+ await testAvailability({
+ allowDNRFeedback: true,
+ testExpectations: {
+ declarativeNetRequest_available: true,
+ // feature allowed, but missing declarativeNetRequestFeedback:
+ testMatchOutcome_available: false,
+ },
+ manifest: {
+ permissions: ["declarativeNetRequestWithHostAccess"],
+ },
+ });
+});
+
+add_task(async function with_all_declarativeNetRequest_permissions() {
+ await testAvailability({
+ allowDNRFeedback: true,
+ testExpectations: {
+ declarativeNetRequest_available: true,
+ // feature allowed, but missing declarativeNetRequestFeedback:
+ testMatchOutcome_available: false,
+ },
+ manifest: {
+ permissions: [
+ "declarativeNetRequest",
+ "declarativeNetRequestWithHostAccess",
+ ],
+ },
+ });
+});
+
+add_task(async function no_declarativeNetRequest_permission() {
+ await testAvailability({
+ allowDNRFeedback: true,
+ testExpectations: {
+ // Just declarativeNetRequestFeedback should not unlock the API.
+ declarativeNetRequest_available: false,
+ },
+ manifest: {
+ permissions: ["declarativeNetRequestFeedback"],
+ },
+ });
+});
+
+add_task(async function with_declarativeNetRequestFeedback_permission() {
+ await testAvailability({
+ allowDNRFeedback: true,
+ testExpectations: {
+ declarativeNetRequest_available: true,
+ // feature allowed, and all permissions specified:
+ testMatchOutcome_available: true,
+ },
+ manifest: {
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ },
+ });
+});
+
+add_task(async function declarativeNetRequestFeedback_without_feature() {
+ await testAvailability({
+ allowDNRFeedback: false,
+ testExpectations: {
+ declarativeNetRequest_available: true,
+ // all permissions set, but DNR feedback feature not allowed.
+ testMatchOutcome_available: kTestMatchOutcomeNotAllowed,
+ },
+ manifest: {
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ },
+ });
+});
+
+add_task(
+ { pref_set: [["extensions.dnr.feedback", true]] },
+ async function declarativeNetRequestFeedback_is_optional() {
+ async function background() {
+ async function assertTestMatchOutcomeEnabled(expected, description) {
+ let enabled;
+ try {
+ // testAvailability already checks the errors etc, so here we only
+ // care about the method working vs not working.
+ await browser.declarativeNetRequest.testMatchOutcome({
+ url: "https://example.com/",
+ type: "other",
+ });
+ enabled = true;
+ } catch (e) {
+ enabled = false;
+ }
+ browser.test.assertEq(expected, enabled, description);
+ }
+
+ await assertTestMatchOutcomeEnabled(false, "disabled when not granted");
+
+ await new Promise(resolve => {
+ // browser.test.withHandlingUserInput would have been simpler, but due
+ // to bug 1598804 it cannot be used.
+ browser.test.onMessage.addListener(async msg => {
+ browser.test.assertEq("withHandlingUserInput_ok", msg, "Resuming");
+ await browser.permissions.request({
+ permissions: ["declarativeNetRequestFeedback"],
+ });
+ browser.test.sendMessage("withHandlingUserInput_done");
+ resolve();
+ });
+ browser.test.sendMessage("withHandlingUserInput_wanted");
+ });
+
+ await assertTestMatchOutcomeEnabled(true, "enabled by permission");
+
+ await browser.permissions.remove({
+ permissions: ["declarativeNetRequestFeedback"],
+ });
+ await assertTestMatchOutcomeEnabled(false, "disabled after perm removal");
+
+ browser.test.sendMessage("done");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequestWithHostAccess"],
+ optional_permissions: ["declarativeNetRequestFeedback"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("withHandlingUserInput_wanted");
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("withHandlingUserInput_ok");
+ await extension.awaitMessage("withHandlingUserInput_done");
+ });
+ await extension.awaitMessage("done");
+ await extension.unload();
+ }
+);
+
+add_task(async function test_dnr_limits_namespace_properties() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest"],
+ },
+ background() {
+ browser.test.assertEq(
+ "_dynamic",
+ browser.declarativeNetRequest.DYNAMIC_RULESET_ID,
+ "Value of DYNAMIC_RULESET_ID constant"
+ );
+ browser.test.assertEq(
+ "_session",
+ browser.declarativeNetRequest.SESSION_RULESET_ID,
+ "Value of SESSION_RULESET_ID constant"
+ );
+ const {
+ GUARANTEED_MINIMUM_STATIC_RULES,
+ MAX_NUMBER_OF_STATIC_RULESETS,
+ MAX_NUMBER_OF_ENABLED_STATIC_RULESETS,
+ MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES,
+ MAX_NUMBER_OF_REGEX_RULES,
+ } = browser.declarativeNetRequest;
+ browser.test.sendMessage("dnr-namespace-properties", {
+ GUARANTEED_MINIMUM_STATIC_RULES,
+ MAX_NUMBER_OF_STATIC_RULESETS,
+ MAX_NUMBER_OF_ENABLED_STATIC_RULESETS,
+ MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES,
+ MAX_NUMBER_OF_REGEX_RULES,
+ });
+ },
+ });
+
+ await extension.startup();
+
+ Assert.deepEqual(
+ await extension.awaitMessage("dnr-namespace-properties"),
+ ExtensionDNRLimits,
+ "Got the expected limits values set on the dnr namespace"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_download.js
new file mode 100644
index 0000000000..cd24b75855
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_download.js
@@ -0,0 +1,193 @@
+"use strict";
+
+let server = createHttpServer({ hosts: ["example.com"] });
+let downloadReqCount = 0;
+server.registerPathHandler("/downloadtest", (req, res) => {
+ ++downloadReqCount;
+});
+
+add_setup(async () => {
+ let downloadDir = await IOUtils.createUniqueDirectory(
+ Services.dirsvc.get("TmpD", Ci.nsIFile).path,
+ "downloadDirForDnrDownloadTest"
+ );
+ info(`Using download directory ${downloadDir.path}`);
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setCharPref("browser.download.dir", downloadDir);
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ try {
+ await IOUtils.remove(downloadDir);
+ } catch (e) {
+ info(`Failed to remove ${downloadDir} because: ${e}`);
+ // Downloaded files should have been deleted by tests.
+ // Clean up + report error otherwise.
+ let children = await IOUtils.getChildren(downloadDir).catch(e => e);
+ ok(false, `Unexpected files in downloadDir: ${children}`);
+ await IOUtils.remove(downloadDir, { recursive: true });
+ }
+ });
+
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+});
+
+// Test for Bug 1579911: Check that download requests created by the
+// downloads.download API can be observed by extensions.
+// The webRequest version is in test_ext_webRequest_download.js.
+add_task(async function test_download_api_can_be_blocked_by_dnr() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest", "downloads"],
+ // No host_permissions here because neither the downloads nor the DNR API
+ // require host permissions to download and/or block the request.
+ },
+ // Not needed, but to rule out downloads being blocked by CSP:
+ allowInsecureRequests: true,
+ background: async function () {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { urlFilter: "|http://example.com/downloadtest" },
+ action: { type: "block" },
+ },
+ ],
+ });
+
+ browser.downloads.onChanged.addListener(delta => {
+ browser.test.assertEq(delta.state.current, "interrupted");
+ browser.test.sendMessage("done");
+ });
+
+ await browser.downloads.download({
+ url: "http://example.com/downloadtest",
+ filename: "example.txt",
+ });
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+
+ Assert.equal(downloadReqCount, 0, "Did not expect any download requests");
+});
+
+add_task(async function test_download_api_ignores_dnr_from_other_extension() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest"],
+ },
+ background: async function () {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { urlFilter: "|http://example.com/downloadtest" },
+ action: { type: "block" },
+ },
+ ],
+ });
+
+ browser.test.sendMessage("dnr_registered");
+ },
+ });
+
+ let otherExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["downloads"],
+ },
+ background: async function () {
+ let downloadDonePromise = new Promise(resolve => {
+ browser.downloads.onChanged.addListener(delta => {
+ if (delta.state.current === "interrupted") {
+ browser.test.fail("Download was unexpectedly interrupted");
+ browser.test.notifyFail("done");
+ } else if (delta.state.current === "complete") {
+ resolve();
+ }
+ });
+ });
+
+ // This download should not have been interrupted by the other extension,
+ // because declarativeNetRequest cannot match requests from other
+ // extensions.
+ let downloadId = await browser.downloads.download({
+ url: "http://example.com/downloadtest",
+ filename: "example_from_other_ext.txt",
+ });
+ await downloadDonePromise;
+ browser.test.log("Download completed, removing file...");
+ // TODO bug 1654819: On Windows the file may be recreated.
+ await browser.downloads.removeFile(downloadId);
+ browser.test.notifyPass("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("dnr_registered");
+
+ await otherExtension.startup();
+ await otherExtension.awaitFinish("done");
+ await otherExtension.unload();
+ await extension.unload();
+
+ Assert.equal(downloadReqCount, 1, "Expected one download request");
+ downloadReqCount = 0;
+});
+
+add_task(
+ {
+ pref_set: [["extensions.dnr.match_requests_from_other_extensions", true]],
+ },
+ async function test_download_api_dnr_blocks_other_extension_with_pref() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest"],
+ },
+ background: async function () {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { urlFilter: "|http://example.com/downloadtest" },
+ action: { type: "block" },
+ },
+ ],
+ });
+
+ browser.test.sendMessage("dnr_registered");
+ },
+ });
+ let otherExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["downloads"],
+ },
+ background: async function () {
+ browser.downloads.onChanged.addListener(delta => {
+ browser.test.assertEq(delta.state.current, "interrupted");
+ browser.test.sendMessage("done");
+ });
+ await browser.downloads.download({
+ url: "http://example.com/downloadtest",
+ filename: "example_from_other_ext_with_pref.txt",
+ });
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("dnr_registered");
+ await otherExtension.startup();
+ await otherExtension.awaitMessage("done");
+ await otherExtension.unload();
+ await extension.unload();
+
+ Assert.equal(downloadReqCount, 0, "Did not expect any download requests");
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_dynamic_rules.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_dynamic_rules.js
new file mode 100644
index 0000000000..4ba120852f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_dynamic_rules.js
@@ -0,0 +1,1245 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs",
+ ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs",
+ ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+Services.scriptloader.loadSubScript(
+ Services.io.newFileURI(do_get_file("head_dnr.js")).spec,
+ this
+);
+
+const { promiseStartupManager, promiseRestartManager } = AddonTestUtils;
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerPathHandler("/", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.write("response from server");
+});
+
+add_setup(async () => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.feedback", true);
+
+ setupTelemetryForTests();
+
+ await promiseStartupManager();
+});
+
+// This function is serialized and called in the context of the test extension's
+// background page. dnrTestUtils is passed to the background function.
+function makeDnrTestUtils() {
+ const dnrTestUtils = {};
+ const dnr = browser.declarativeNetRequest;
+
+ function serializeForLog(data) {
+ // JSON-stringify, but drop null values (replacing them with undefined
+ // causes JSON.stringify to drop them), so that optional keys with the null
+ // values are hidden.
+ let str = JSON.stringify(data, rep => rep ?? undefined);
+ return str;
+ }
+
+ async function testInvalidRule(rule, expectedError, isSchemaError) {
+ if (isSchemaError) {
+ // Schema validation error = thrown error instead of a rejection.
+ browser.test.assertThrows(
+ () => dnr.updateDynamicRules({ addRules: [rule] }),
+ expectedError,
+ `Rule should be invalid (schema-validated): ${serializeForLog(rule)}`
+ );
+ } else {
+ await browser.test.assertRejects(
+ dnr.updateDynamicRules({ addRules: [rule] }),
+ expectedError,
+ `Rule should be invalid: ${serializeForLog(rule)}`
+ );
+ }
+ }
+
+ Object.assign(dnrTestUtils, {
+ testInvalidRule,
+ serializeForLog,
+ });
+ return dnrTestUtils;
+}
+
+async function runAsDNRExtension({
+ background,
+ unloadTestAtEnd = true,
+ awaitFinish = false,
+ id = "test-dynamic-rules@test-extension",
+}) {
+ const testExtensionParams = {
+ background: `(${background})((${makeDnrTestUtils})())`,
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ browser_specific_settings: {
+ gecko: { id },
+ },
+ },
+ };
+ const extension = ExtensionTestUtils.loadExtension(testExtensionParams);
+ await extension.startup();
+ if (awaitFinish) {
+ await extension.awaitFinish();
+ }
+ if (unloadTestAtEnd) {
+ await extension.unload();
+ }
+ return { extension, testExtensionParams };
+}
+
+function callTestMessageHandler(extension, testMessage, ...args) {
+ extension.sendMessage(testMessage, ...args);
+ return extension.awaitMessage(`${testMessage}:done`);
+}
+
+add_task(async function test_dynamic_rule_registration() {
+ await runAsDNRExtension({
+ background: async () => {
+ const dnr = browser.declarativeNetRequest;
+
+ await dnr.updateDynamicRules({
+ addRules: [{ id: 1, condition: {}, action: { type: "block" } }],
+ });
+
+ const url = "https://example.com/some-dummy-url";
+ const type = "font";
+ browser.test.assertDeepEq(
+ { matchedRules: [{ ruleId: 1, rulesetId: "_dynamic" }] },
+ await dnr.testMatchOutcome({ url, type }),
+ "Dynamic rule matched after registration"
+ );
+
+ await dnr.updateDynamicRules({
+ removeRuleIds: [
+ 1,
+ 1234567890, // Invalid rules should be ignored.
+ ],
+ addRules: [{ id: 2, condition: {}, action: { type: "block" } }],
+ });
+ browser.test.assertDeepEq(
+ { matchedRules: [{ ruleId: 2, rulesetId: "_dynamic" }] },
+ await dnr.testMatchOutcome({ url, type }),
+ "Dynamic rule matched after update"
+ );
+
+ await dnr.updateDynamicRules({ removeRuleIds: [2] });
+ browser.test.assertDeepEq(
+ { matchedRules: [] },
+ await dnr.testMatchOutcome({ url, type }),
+ "Dynamic rule not matched after unregistration"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function test_dynamic_rules_count_limits() {
+ await runAsDNRExtension({
+ unloadTestAtEnd: true,
+ awaitFinish: true,
+ background: async () => {
+ const dnr = browser.declarativeNetRequest;
+ const [dyamicRules, sessionRules] = await Promise.all([
+ dnr.getDynamicRules(),
+ dnr.getSessionRules(),
+ ]);
+
+ browser.test.assertDeepEq(
+ { session: [], dynamic: [] },
+ { session: sessionRules, dynamic: dyamicRules },
+ "Expect no session and no dynamic rules"
+ );
+
+ const { MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES } = dnr;
+ const DUMMY_RULE = {
+ action: { type: "block" },
+ condition: { resourceTypes: ["main_frame"] },
+ };
+ const rules = [];
+ for (let i = 0; i < MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES; i++) {
+ rules.push({ ...DUMMY_RULE, id: i + 1 });
+ }
+
+ await browser.test.assertRejects(
+ dnr.updateDynamicRules({
+ addRules: [
+ ...rules,
+ { ...DUMMY_RULE, id: MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 1 },
+ ],
+ }),
+ `Number of rules in ruleset "_dynamic" exceeds MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES.`,
+ "Got the expected rejection of exceeding the number of dynamic rules allowed"
+ );
+
+ await dnr.updateDynamicRules({
+ addRules: rules,
+ });
+ browser.test.assertEq(
+ 5000,
+ (await dnr.getDynamicRules()).length,
+ "Got the expected number of dynamic rules stored"
+ );
+
+ await dnr.updateDynamicRules({
+ removeRuleIds: rules.map(r => r.id),
+ });
+
+ browser.test.assertEq(
+ 0,
+ (await dnr.getDynamicRules()).length,
+ "All dynamic rules should have been removed"
+ );
+
+ browser.test.log(
+ "Verify rules count limits with multiple async API calls"
+ );
+
+ const [updateDynamicRulesSingle, updateDynamicRulesTooMany] =
+ await Promise.allSettled([
+ dnr.updateDynamicRules({
+ addRules: [
+ {
+ ...DUMMY_RULE,
+ id: MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 1,
+ },
+ ],
+ }),
+ dnr.updateDynamicRules({ addRules: rules }),
+ ]);
+
+ browser.test.assertDeepEq(
+ updateDynamicRulesSingle,
+ { status: "fulfilled", value: undefined },
+ "Expect the first updateDynamicRules call to be successful"
+ );
+
+ await browser.test.assertRejects(
+ updateDynamicRulesTooMany?.status === "rejected"
+ ? Promise.reject(updateDynamicRulesTooMany.reason)
+ : Promise.resolve(),
+ `Number of rules in ruleset "_dynamic" exceeds MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES.`,
+ "Got the expected rejection on the second call exceeding the number of dynamic rules allowed"
+ );
+
+ browser.test.assertDeepEq(
+ (await dnr.getDynamicRules()).map(rule => rule.id),
+ [MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 1],
+ "Got the expected dynamic rules"
+ );
+
+ await dnr.updateDynamicRules({
+ removeRuleIds: [MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 1],
+ });
+
+ const [updateSessionResult, updateDynamicResult] =
+ await Promise.allSettled([
+ dnr.updateSessionRules({ addRules: rules }),
+ dnr.updateDynamicRules({ addRules: rules }),
+ ]);
+
+ browser.test.assertDeepEq(
+ updateDynamicResult,
+ { status: "fulfilled", value: undefined },
+ "Expect the number of dynamic rules to be still allowed, despite the session rule added"
+ );
+
+ // NOTE: In this test we do not exceed the quota of session rules. The
+ // updateSessionRules call here is to verify that the quota of session and
+ // dynamic rules are separate. The limits for session rules are tested
+ // by session_rules_total_rule_limit in test_ext_dnr_session_rules.js.
+ browser.test.assertDeepEq(
+ updateSessionResult,
+ { status: "fulfilled", value: undefined },
+ "Got expected success from the updateSessionRules request"
+ );
+
+ browser.test.assertDeepEq(
+ { sessionRulesCount: 5000, dynamicRulesCount: 5000 },
+ {
+ sessionRulesCount: (await dnr.getSessionRules()).length,
+ dynamicRulesCount: (await dnr.getDynamicRules()).length,
+ },
+ "Got expected session and dynamic rules counts"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function test_stored_dynamic_rules_exceeding_limits() {
+ const { extension } = await runAsDNRExtension({
+ unloadTestAtEnd: false,
+ awaitFinish: false,
+ background: async () => {
+ const dnr = browser.declarativeNetRequest;
+
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ switch (msg) {
+ case "createDynamicRules": {
+ const [{ updateRuleOptions }] = args;
+ await dnr.updateDynamicRules(updateRuleOptions);
+ break;
+ }
+ case "assertGetDynamicRulesCount": {
+ const [{ expectedRulesCount }] = args;
+ browser.test.assertEq(
+ expectedRulesCount,
+ (await dnr.getDynamicRules()).length,
+ "getDynamicRules() resolves to the expected number of dynamic rules"
+ );
+ break;
+ }
+ default:
+ browser.test.fail(
+ `Got unexpected unhandled test message: "${msg}"`
+ );
+ break;
+ }
+ browser.test.sendMessage(`${msg}:done`);
+ });
+ browser.test.sendMessage("bgpage:ready");
+ },
+ });
+
+ const initialRules = [getDNRRule({ id: 1 })];
+ await extension.awaitMessage("bgpage:ready");
+ await callTestMessageHandler(extension, "createDynamicRules", {
+ updateRuleOptions: { addRules: initialRules },
+ });
+ await callTestMessageHandler(extension, "assertGetDynamicRulesCount", {
+ expectedRulesCount: 1,
+ });
+
+ const extUUID = extension.uuid;
+ const dnrStore = ExtensionDNRStore._getStoreForTesting();
+ await dnrStore._savePromises.get(extUUID);
+ const { storeFile } = dnrStore.getFilePaths(extUUID);
+
+ await extension.addon.disable();
+
+ ok(
+ !dnrStore._dataPromises.has(extUUID),
+ "DNR store read data promise cleared after the extension has been disabled"
+ );
+ ok(
+ !dnrStore._data.has(extUUID),
+ "DNR store data cleared from memory after the extension has been disabled"
+ );
+
+ ok(await IOUtils.exists(storeFile), `DNR storeFile ${storeFile} found`);
+ const dnrDataFromFile = await IOUtils.readJSON(storeFile, {
+ decompress: true,
+ });
+
+ const { MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES } = ExtensionDNRLimits;
+
+ const expectedDynamicRules = [];
+ const unexpectedDynamicRules = [];
+
+ for (let i = 0; i < MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 5; i++) {
+ const rule = getDNRRule({ id: i + 1 });
+ if (i < MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES) {
+ expectedDynamicRules.push(rule);
+ } else {
+ unexpectedDynamicRules.push(rule);
+ }
+ }
+
+ const tooManyDynamicRules = [
+ ...expectedDynamicRules,
+ ...unexpectedDynamicRules,
+ ];
+
+ const dnrDataNew = {
+ schemaVersion: dnrDataFromFile.schemaVersion,
+ extVersion: extension.extension.version,
+ staticRulesets: [],
+ dynamicRuleset: getSchemaNormalizedRules(extension, tooManyDynamicRules),
+ };
+
+ await IOUtils.writeJSON(storeFile, dnrDataNew, { compress: true });
+
+ const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => {
+ await extension.addon.enable();
+ await extension.awaitMessage("bgpage:ready");
+ });
+
+ await callTestMessageHandler(extension, "assertGetDynamicRulesCount", {
+ expectedRulesCount: 0,
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ {
+ message: new RegExp(
+ `Ignoring dynamic ruleset in extension "${extension.id}" because: Number of rules in ruleset "_dynamic" exceeds MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES`
+ ),
+ },
+ ],
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_save_and_load_dynamic_rules() {
+ let { extension, testExtensionParams } = await runAsDNRExtension({
+ unloadTestAtEnd: false,
+ awaitFinish: false,
+ background: async dnrTestUtils => {
+ const dnr = browser.declarativeNetRequest;
+
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ switch (msg) {
+ case "assertGetDynamicRules": {
+ const [{ expectedRules }] = args;
+ browser.test.assertDeepEq(
+ expectedRules,
+ await dnr.getDynamicRules(),
+ "getDynamicRules() resolves to the expected dynamic rules"
+ );
+ break;
+ }
+ case "testUpdateDynamicRules": {
+ const [{ updateRulesRequests, expectedRules }] = args;
+ const promiseResults = await Promise.allSettled(
+ updateRulesRequests.map(updateRuleOptions =>
+ dnr.updateDynamicRules(updateRuleOptions)
+ )
+ );
+
+ // All calls should have been resolved successfully.
+ for (const [i, request] of updateRulesRequests.entries()) {
+ browser.test.assertDeepEq(
+ { status: "fulfilled", value: undefined },
+ promiseResults[i],
+ `Expect resolved updateDynamicRules request for ${dnrTestUtils.serializeForLog(
+ request
+ )}`
+ );
+ }
+
+ browser.test.assertDeepEq(
+ expectedRules,
+ await dnr.getDynamicRules(),
+ "getDynamicRules resolves to the expected updated dynamic rules"
+ );
+ break;
+ }
+ case "testInvalidDynamicAddRule": {
+ const [{ rule, expectedError, isSchemaError, isErrorRegExp }] =
+ args;
+ await dnrTestUtils.testInvalidRule(
+ rule,
+ expectedError,
+ isSchemaError,
+ isErrorRegExp
+ );
+ break;
+ }
+ default:
+ browser.test.fail(
+ `Got unexpected unhandled test message: "${msg}"`
+ );
+ break;
+ }
+
+ browser.test.sendMessage(`${msg}:done`);
+ });
+
+ browser.test.sendMessage("bgpage:ready");
+ },
+ });
+
+ await extension.awaitMessage("bgpage:ready");
+ await callTestMessageHandler(extension, "assertGetDynamicRules", {
+ expectedRules: [],
+ });
+
+ const rules = [
+ getDNRRule({
+ id: 1,
+ action: { type: "allow" },
+ condition: { resourceTypes: ["main_frame"] },
+ }),
+ getDNRRule({
+ id: 2,
+ action: { type: "block" },
+ condition: { resourceTypes: ["main_frame", "script"] },
+ }),
+ ];
+
+ info("Verify updateDynamicRules adding new valid rules");
+ // Send two concurrent API requests, the first one adds 3 rules and the second
+ // one removing a rule defined in the first call, the result of the combined
+ // API calls is expected to only store 2 dynamic rules in the DNR store.
+ await callTestMessageHandler(extension, "testUpdateDynamicRules", {
+ updateRulesRequests: [
+ { addRules: [...rules, getDNRRule({ id: 3 })] },
+ { removeRuleIds: [3] },
+ ],
+ expectedRules: getSchemaNormalizedRules(extension, rules),
+ });
+
+ const extUUID = extension.uuid;
+ const dnrStore = ExtensionDNRStore._getStoreForTesting();
+ await dnrStore._savePromises.get(extUUID);
+ const { storeFile } = dnrStore.getFilePaths(extUUID);
+ const dnrDataFromFile = await IOUtils.readJSON(storeFile, {
+ decompress: true,
+ });
+
+ Assert.deepEqual(
+ dnrDataFromFile.dynamicRuleset,
+ getSchemaNormalizedRules(extension, rules),
+ "Got the expected rules stored on disk"
+ );
+
+ info("Verify updateDynamicRules rejects on new invalid rules");
+ await callTestMessageHandler(extension, "testInvalidDynamicAddRule", {
+ rule: rules[0],
+ expectedError: "Duplicate rule ID: 1",
+ isSchemaError: false,
+ });
+
+ await callTestMessageHandler(extension, "testInvalidDynamicAddRule", {
+ rule: getDNRRule({ action: { type: "invalid-action" } }),
+ expectedError:
+ /addRules.0.action.type: Invalid enumeration value "invalid-action"/,
+ isSchemaError: true,
+ });
+
+ info("Expect dynamic rules to not have been changed");
+ await callTestMessageHandler(extension, "assertGetDynamicRules", {
+ expectedRules: getSchemaNormalizedRules(extension, rules),
+ });
+
+ Assert.deepEqual(
+ dnrStore._data.get(extUUID).dynamicRuleset,
+ getSchemaNormalizedRules(extension, rules),
+ "Got the expected dynamic rules in the DNR store"
+ );
+
+ info("Verify dynamic rules loaded back from disk on addon restart");
+ ok(await IOUtils.exists(storeFile), `DNR storeFile ${storeFile} found`);
+
+ // force deleting the data stored in memory to confirm if it being loaded again from
+ // the files stored on disk.
+ dnrStore._data.delete(extUUID);
+ dnrStore._dataPromises.delete(extUUID);
+
+ const { addon } = extension;
+ await addon.disable();
+
+ ok(
+ !dnrStore._dataPromises.has(extUUID),
+ "DNR store read data promise cleared after the extension has been disabled"
+ );
+ ok(
+ !dnrStore._data.has(extUUID),
+ "DNR store data cleared from memory after the extension has been disabled"
+ );
+
+ await addon.enable();
+ await extension.awaitMessage("bgpage:ready");
+
+ info("Expect dynamic rules to have been loaded back");
+ await callTestMessageHandler(extension, "assertGetDynamicRules", {
+ expectedRules: getSchemaNormalizedRules(extension, rules),
+ });
+
+ Assert.deepEqual(
+ dnrStore._data.get(extUUID).dynamicRuleset,
+ getSchemaNormalizedRules(extension, rules),
+ "Got the expected dynamic rules loaded back from the DNR store after addon restart"
+ );
+
+ info("Verify dynamic rules loaded back as expected on AOM restart");
+ dnrStore._data.delete(extUUID);
+ dnrStore._dataPromises.delete(extUUID);
+
+ // NOTE: promiseRestartManager will not be enough to make sure the
+ // DNR store data for the test extension is going to be loaded from
+ // the DNR startup cache file.
+ // See test_ext_dnr_startup_cache.js for a test case that more completely
+ // simulates ExtensionDNRStore initialization on browser restart.
+ await promiseRestartManager();
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("bgpage:ready");
+
+ await callTestMessageHandler(extension, "assertGetDynamicRules", {
+ expectedRules: getSchemaNormalizedRules(extension, rules),
+ });
+
+ // Verify the dynamic rules are converted back into Rule class instances
+ // as expected when loaded back from the DNR store file
+ Assert.ok(
+ !!dnrStore._data.get(extUUID).dynamicRuleset.length,
+ "Expected dynamic rules to have been loaded back from the DNR store file"
+ );
+ Assert.deepEqual(
+ dnrStore._data
+ .get(extUUID)
+ .dynamicRuleset.filter(rule => rule.constructor.name !== "Rule"),
+ [],
+ "Expect dynamic rules loaded back from the DNR store file to be converted to Rule class instances"
+ );
+
+ Assert.deepEqual(
+ dnrStore._data.get(extUUID).dynamicRuleset,
+ getSchemaNormalizedRules(extension, rules),
+ "Got the expected dynamic rules loaded back from the DNR store after AOM restart"
+ );
+
+ info(
+ "Verify updateDynamicRules adding new valid rules and remove one of the existing"
+ );
+ // Expect the first rule to be removed and a new one being added.
+ const newRule3 = getDNRRule({
+ id: 3,
+ action: { type: "allow" },
+ condition: { resourceTypes: ["main_frame"] },
+ });
+ const updatedRules = [rules[1], newRule3];
+
+ await callTestMessageHandler(extension, "testUpdateDynamicRules", {
+ updateRulesRequests: [{ addRules: [newRule3], removeRuleIds: [1] }],
+ expectedRules: getSchemaNormalizedRules(extension, updatedRules),
+ });
+
+ info("Verify dynamic rules preserved across addon updates");
+
+ const staticRules = [
+ getDNRRule({
+ id: 4,
+ action: { type: "block" },
+ condition: { resourceTypes: ["xmlhttprequest"] },
+ }),
+ ];
+ await extension.upgrade({
+ ...testExtensionParams,
+ manifest: {
+ ...testExtensionParams.manifest,
+ version: "2.0",
+ declarative_net_request: {
+ rule_resources: [
+ {
+ id: "ruleset_1",
+ enabled: true,
+ path: "ruleset_1.json",
+ },
+ ],
+ },
+ },
+ files: { "ruleset_1.json": JSON.stringify(staticRules) },
+ });
+ await extension.awaitMessage("bgpage:ready");
+
+ await callTestMessageHandler(extension, "assertGetDynamicRules", {
+ expectedRules: getSchemaNormalizedRules(extension, updatedRules),
+ });
+
+ info(
+ "Verify static rules included in the new addon version have been loaded"
+ );
+
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset_1: getSchemaNormalizedRules(extension, staticRules),
+ });
+
+ info("Verify rules after extension downgrade");
+ await extension.upgrade({
+ ...testExtensionParams,
+ manifest: {
+ ...testExtensionParams.manifest,
+ version: "1.0",
+ },
+ });
+ await extension.awaitMessage("bgpage:ready");
+
+ info("Verify stored dynamic rules are unchanged");
+
+ await callTestMessageHandler(extension, "assertGetDynamicRules", {
+ expectedRules: getSchemaNormalizedRules(extension, updatedRules),
+ });
+
+ info(
+ "Verify static rules included in the new addon version are cleared on downgrade to previous version"
+ );
+ await assertDNRStoreData(dnrStore, extension, {});
+
+ info("Verify rules after extension upgrade to one without DNR permissions");
+ await extension.upgrade({
+ ...testExtensionParams,
+ manifest: {
+ ...testExtensionParams.manifest,
+ permissions: [],
+ version: "1.1",
+ },
+ background: async () => {
+ browser.test.assertEq(
+ browser.declarativeNetRequest,
+ undefined,
+ "Expect DNR API namespace to not be available"
+ );
+ browser.test.sendMessage("bgpage:ready");
+ },
+ });
+ await extension.awaitMessage("bgpage:ready");
+ ok(
+ !dnrStore._dataPromises.has(extension.uuid),
+ "Expect dnrStore to not have any promise for the extension DNR data being loaded"
+ );
+ ok(
+ !ExtensionDNR.getRuleManager(
+ extension.extension,
+ false /* createIfMissing */
+ ),
+ "Expect no ruleManager found for the extenson"
+ );
+
+ info(
+ "Verify rules are loaded back after upgrading again to one with DNR permissions"
+ );
+ await extension.upgrade({
+ ...testExtensionParams,
+ manifest: {
+ ...testExtensionParams.manifest,
+ version: "1.2",
+ },
+ });
+ await extension.awaitMessage("bgpage:ready");
+
+ // NOTE: To make sure that the test extension rule manager is removed
+ // on the extension shutdown also when the declarativeNetRequest
+ // ExtensionAPI class instance has not been created at all, this part
+ // on the test is purposely not calling any declarativeNetRequest API method
+ // not calling ExtensionDNR.ensureInitialized, instead we wait for the
+ // RuleManager instance to be created and then we disable the
+ // test extension and assert that the RuleManager has been cleared.
+ let ruleManager = await TestUtils.waitForCondition(
+ () =>
+ ExtensionDNR.getRuleManager(
+ extension.extension,
+ /* createIfMissing= */ false
+ ),
+ "Wait for the test extension RuleManager to have neem created"
+ );
+ Assert.ok(ruleManager, "Rule manager exists before unload");
+ Assert.deepEqual(
+ ruleManager.getDynamicRules(),
+ getSchemaNormalizedRules(extension, updatedRules),
+ "Found the expected dynamic rules in the Rule manager"
+ );
+ await extension.addon.disable();
+ Assert.ok(
+ !ExtensionDNR.getRuleManager(
+ extension.extension,
+ /* createIfMissing= */ false
+ ),
+ "Rule manager erased after unload"
+ );
+
+ await extension.addon.enable();
+ await extension.awaitMessage("bgpage:ready");
+ await callTestMessageHandler(extension, "assertGetDynamicRules", {
+ expectedRules: getSchemaNormalizedRules(extension, updatedRules),
+ });
+
+ info("Verify dynamic rules updates after corrupted storage");
+
+ async function testLoadedRulesAfterDataCorruption({
+ name,
+ asyncWriteStoreFile,
+ expectedCorruptFile,
+ }) {
+ info(`Tampering DNR store data: ${name}`);
+
+ await extension.addon.disable();
+ Assert.ok(
+ !ExtensionDNR.getRuleManager(
+ extension.extension,
+ /* createIfMissing= */ false
+ ),
+ "Rule manager erased after unload"
+ );
+
+ ok(
+ !dnrStore._dataPromises.has(extUUID),
+ "DNR store read data promise cleared after the extension has been disabled"
+ );
+ ok(
+ !dnrStore._data.has(extUUID),
+ "DNR store data cleared from memory after the extension has been disabled"
+ );
+
+ await asyncWriteStoreFile();
+
+ await extension.addon.enable();
+ await extension.awaitMessage("bgpage:ready");
+
+ await TestUtils.waitForCondition(
+ () => IOUtils.exists(`${expectedCorruptFile}`),
+ `Wait for the "${expectedCorruptFile}" file to have been created`
+ );
+
+ ok(
+ !(await IOUtils.exists(storeFile)),
+ "Corrupted store file expected to be removed"
+ );
+
+ await callTestMessageHandler(extension, "assertGetDynamicRules", {
+ expectedRules: [],
+ });
+
+ const newRules = [getDNRRule({ id: 3 })];
+ const expectedRules = getSchemaNormalizedRules(extension, newRules);
+ await callTestMessageHandler(extension, "testUpdateDynamicRules", {
+ updateRulesRequests: [{ addRules: newRules }],
+ expectedRules,
+ });
+
+ await TestUtils.waitForCondition(
+ () => IOUtils.exists(storeFile),
+ `Wait for the "${storeFile}" file to have been created`
+ );
+
+ const newData = await IOUtils.readJSON(storeFile, { decompress: true });
+ Assert.deepEqual(
+ newData.dynamicRuleset,
+ expectedRules,
+ "Expect the new rules to have been stored on disk"
+ );
+ }
+
+ await testLoadedRulesAfterDataCorruption({
+ name: "invalid lz4 header",
+ asyncWriteStoreFile: () =>
+ IOUtils.writeUTF8(storeFile, "not an lz4 compressed file", {
+ compress: false,
+ }),
+ expectedCorruptFile: `${storeFile}.corrupt`,
+ });
+
+ await testLoadedRulesAfterDataCorruption({
+ name: "invalid json data",
+ asyncWriteStoreFile: () =>
+ IOUtils.writeUTF8(storeFile, "invalid json data", { compress: true }),
+ expectedCorruptFile: `${storeFile}-1.corrupt`,
+ });
+
+ await testLoadedRulesAfterDataCorruption({
+ name: "empty json data",
+ asyncWriteStoreFile: () =>
+ IOUtils.writeUTF8(storeFile, "{}", { compress: true }),
+ expectedCorruptFile: `${storeFile}-2.corrupt`,
+ });
+
+ await testLoadedRulesAfterDataCorruption({
+ name: "invalid staticRulesets property type",
+ asyncWriteStoreFile: () =>
+ IOUtils.writeUTF8(
+ storeFile,
+ JSON.stringify({
+ schemaVersion: dnrDataFromFile.schemaVersion,
+ extVersion: extension.extension.version,
+ staticRulesets: "Not an array",
+ }),
+ { compress: true }
+ ),
+ expectedCorruptFile: `${storeFile}-3.corrupt`,
+ });
+
+ await testLoadedRulesAfterDataCorruption({
+ name: "invalid dynamicRuleset property type",
+ asyncWriteStoreFile: () =>
+ IOUtils.writeUTF8(
+ storeFile,
+ JSON.stringify({
+ schemaVersion: dnrDataFromFile.schemaVersion,
+ extVersion: extension.extension.version,
+ staticRulesets: [],
+ dynamicRuleset: "Not an array",
+ }),
+ { compress: true }
+ ),
+ expectedCorruptFile: `${storeFile}-4.corrupt`,
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_tabId_conditions_invalid_in_dynamic_rules() {
+ await runAsDNRExtension({
+ unloadTestAtEnd: true,
+ awaitFinish: true,
+ background: async dnrTestUtils => {
+ await dnrTestUtils.testInvalidRule(
+ { id: 1, action: { type: "block" }, condition: { tabIds: [1] } },
+ "tabIds and excludedTabIds can only be specified in session rules"
+ );
+ await dnrTestUtils.testInvalidRule(
+ {
+ id: 1,
+ action: { type: "block" },
+ condition: { excludedTabIds: [1] },
+ },
+ "tabIds and excludedTabIds can only be specified in session rules"
+ );
+ browser.test.assertDeepEq(
+ [],
+ await browser.declarativeNetRequest.getDynamicRules(),
+ "Expect the invalid rules to not be enabled"
+ );
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function test_dynamic_rules_telemetry() {
+ resetTelemetryData();
+
+ let { extension } = await runAsDNRExtension({
+ unloadTestAtEnd: false,
+ awaitFinish: false,
+ id: "test-dynamic-rules-telemetry@test-extension",
+ background: () => {
+ const dnr = browser.declarativeNetRequest;
+
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ switch (msg) {
+ case "getDynamicRules": {
+ browser.test.sendMessage(
+ `${msg}:done`,
+ await dnr.getDynamicRules()
+ );
+ break;
+ }
+ case "updateDynamicRules": {
+ const { addRules, removeRuleIds } = args[0];
+ await dnr.updateDynamicRules({
+ addRules,
+ removeRuleIds,
+ });
+ browser.test.sendMessage(
+ `${msg}:done`,
+ await dnr.getDynamicRules()
+ );
+ break;
+ }
+ default: {
+ browser.test.fail(`Unexpected test message: ${msg}`);
+ browser.test.sendMessage(`${msg}:done`);
+ break;
+ }
+ }
+ });
+ browser.test.sendMessage("bgpage:ready");
+ },
+ });
+
+ await extension.awaitMessage("bgpage:ready");
+
+ extension.sendMessage("getDynamicRules");
+ Assert.deepEqual(
+ await extension.awaitMessage("getDynamicRules:done"),
+ [],
+ "Expect no dynamic DNR rules"
+ );
+
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ },
+ ],
+ "before test extension have been loaded"
+ );
+
+ const dynamicRules = [
+ getDNRRule({
+ id: 1,
+ action: { type: "block" },
+ condition: {
+ resourceTypes: ["xmlhttprequest"],
+ requestDomains: ["example.com"],
+ },
+ }),
+ getDNRRule({
+ id: 2,
+ action: { type: "block" },
+ condition: {
+ resourceTypes: ["xmlhttprequest"],
+ requestDomains: ["example.org"],
+ },
+ }),
+ ];
+
+ await extension.sendMessage("updateDynamicRules", {
+ addRules: dynamicRules,
+ });
+
+ Assert.deepEqual(
+ await extension.awaitMessage("updateDynamicRules:done"),
+ getSchemaNormalizedRules(extension, dynamicRules),
+ "Expect new dynamic DNR rules to have been added"
+ );
+
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ },
+ ],
+ "no additional rule validation expected for dynamic rules pre-validated on a updateDynamicRules API call"
+ );
+
+ extension.sendMessage("updateDynamicRules", {
+ removeRuleIds: [dynamicRules[1].id],
+ });
+
+ Assert.deepEqual(
+ await extension.awaitMessage("updateDynamicRules:done"),
+ getSchemaNormalizedRules(extension, [dynamicRules[0]]),
+ `Expect dynamic DNR rule with id ${dynamicRules[1].id} to have been removed`
+ );
+
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ },
+ ],
+ "no additional rule validation expected for dynamic rules removed by a updateDynamicRules API call"
+ );
+
+ info("Disabling test extension");
+ await extension.addon.disable();
+
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ },
+ ],
+ "no rule validation hit after disabling the extension"
+ );
+
+ info("Re-enabling test extension");
+ await extension.addon.enable();
+ await extension.awaitMessage("bgpage:ready");
+ info(
+ "Wait for DNR initialization completed for the re-enabled permanently installed extension"
+ );
+ await ExtensionDNR.ensureInitialized(extension.extension);
+
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: 1,
+ },
+ ],
+ "expected rule validation to be hit on re-loading dynamic rules from DNR store file"
+ );
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ // Expected no startup cache file to be loaded or used on re-enabling a disabled extension.
+ {
+ metric: "startupCacheReadSize",
+ mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_BYTES",
+ mirroredType: "histogram",
+ },
+ {
+ metric: "startupCacheReadTime",
+ mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_MS",
+ mirroredType: "histogram",
+ },
+ ],
+ "on loading dnr rules for newly installed extension"
+ );
+
+ info("Verify evaluateRulesCountMax telemetry probe");
+
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "evaluateRulesTime",
+ mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS",
+ mirroredType: "histogram",
+ },
+ {
+ metric: "evaluateRulesCountMax",
+ mirroredName: "extensions.apis.dnr.evaluate_rules_count_max",
+ mirroredType: "scalar",
+ },
+ ],
+ "before any request have been intercepted"
+ );
+
+ Assert.equal(
+ await fetch("http://example.com/").then(res => res.text()),
+ "response from server",
+ "DNR should not block system requests"
+ );
+
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "evaluateRulesTime",
+ mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS",
+ mirroredType: "histogram",
+ },
+ {
+ metric: "evaluateRulesCountMax",
+ mirroredName: "extensions.apis.dnr.evaluate_rules_count_max",
+ mirroredType: "scalar",
+ },
+ ],
+ "after restricted request have been intercepted (but no rules evaluated)"
+ );
+
+ const page = await ExtensionTestUtils.loadContentPage("http://example.com");
+ const callPageFetch = async () => {
+ Assert.equal(
+ await page.spawn([], () => {
+ return this.content.fetch("http://example.com/").then(
+ res => res.text(),
+ err => err.message
+ );
+ }),
+ "NetworkError when attempting to fetch resource.",
+ "DNR should have blocked test request to example.com"
+ );
+ };
+
+ // Expect one sample recorded on evaluating rules for the
+ // top level navigation.
+ let expectedEvaluateRulesTimeSamples = 1;
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "evaluateRulesTime",
+ mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: expectedEvaluateRulesTimeSamples,
+ },
+ ],
+ "evaluateRulesTime should be collected after evaluated rulesets"
+ );
+ // Expect same number of rules currently included in the dynamic ruleset.
+ let expectedEvaluateRulesCountMax = 1;
+ assertDNRTelemetryMetricsGetValueEq(
+ [
+ {
+ metric: "evaluateRulesCountMax",
+ mirroredName: "extensions.apis.dnr.evaluate_rules_count_max",
+ mirroredType: "scalar",
+ expectedGetValue: expectedEvaluateRulesCountMax,
+ },
+ ],
+ "evaluateRulesCountMax should be collected after evaluated dynamic rulesets"
+ );
+
+ extension.sendMessage("updateDynamicRules", {
+ addRules: [dynamicRules[1]],
+ });
+
+ Assert.deepEqual(
+ await extension.awaitMessage("updateDynamicRules:done"),
+ getSchemaNormalizedRules(extension, dynamicRules),
+ `Expect second dynamic DNR rules to have been added`
+ );
+
+ await callPageFetch();
+
+ // Expect one new sample reported on evaluating rules for the
+ // first fetch request originated from the test page.
+ expectedEvaluateRulesTimeSamples += 1;
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "evaluateRulesTime",
+ mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: expectedEvaluateRulesTimeSamples,
+ },
+ ],
+ "evaluateRulesTime should be collected after evaluated rulesets"
+ );
+
+ // Expect new number of rules currently included in the dynamic ruleset.
+ expectedEvaluateRulesCountMax = dynamicRules.length;
+ assertDNRTelemetryMetricsGetValueEq(
+ [
+ {
+ metric: "evaluateRulesCountMax",
+ mirroredName: "extensions.apis.dnr.evaluate_rules_count_max",
+ mirroredType: "scalar",
+ expectedGetValue: expectedEvaluateRulesCountMax,
+ },
+ ],
+ "evaluateRulesCountMax should be increased after evaluated two dynamic rules"
+ );
+
+ extension.sendMessage("updateDynamicRules", {
+ removeRuleIds: [dynamicRules[1].id],
+ });
+
+ await callPageFetch();
+
+ Assert.deepEqual(
+ await extension.awaitMessage("updateDynamicRules:done"),
+ getSchemaNormalizedRules(extension, [dynamicRules[0]]),
+ `Expect only first dynamic DNR rule to have be available`
+ );
+
+ assertDNRTelemetryMetricsGetValueEq(
+ [
+ {
+ metric: "evaluateRulesCountMax",
+ mirroredName: "extensions.apis.dnr.evaluate_rules_count_max",
+ mirroredType: "scalar",
+ expectedGetValue: expectedEvaluateRulesCountMax,
+ },
+ ],
+ "evaluateRulesCountMax should NOT be decreased after removing one dynamic rules"
+ );
+
+ await page.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_modifyHeaders.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_modifyHeaders.js
new file mode 100644
index 0000000000..236cda4e37
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_modifyHeaders.js
@@ -0,0 +1,1072 @@
+"use strict";
+
+const server = createHttpServer({
+ hosts: ["dummy", "restricted", "yes", "no", "maybe", "cookietest"],
+});
+server.registerPathHandler("/echoheaders", (req, res) => {
+ res.setHeader("Content-Type", "application/json");
+ const headers = Object.create(null);
+ for (const nameSupports of req.headers) {
+ const name = nameSupports.QueryInterface(Ci.nsISupportsString).data;
+ // httpd.js automatically concats headers with ",", but in some cases it
+ // stores them separately, joined with "\n".
+ // https://searchfox.org/mozilla-central/rev/c1180ea13e73eb985a49b15c0d90e977a1aa919c/netwerk/test/httpserver/httpd.js#5271-5286
+ const values = req.getHeader(name).split("\n");
+ headers[name] = values.length === 1 ? values[0] : values;
+ }
+
+ // Only keep custom headers, so that the test expectations does not have to
+ // enumerate all headers of interest.
+ function dropDefaultHeader(name) {
+ if (!(name in headers)) {
+ Assert.ok(false, `Header unexpectedly not found: ${name}`);
+ }
+ delete headers[name];
+ }
+ dropDefaultHeader("host");
+ dropDefaultHeader("user-agent");
+ dropDefaultHeader("accept");
+ dropDefaultHeader("accept-language");
+ dropDefaultHeader("accept-encoding");
+ dropDefaultHeader("connection");
+
+ res.write(JSON.stringify(headers));
+});
+
+server.registerPathHandler("/host", (req, res) => {
+ res.write(req.getHeader("Host"));
+});
+
+server.registerPathHandler("/csptest", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.write("EXPECTED_RESPONSE_FOR /csp test");
+});
+server.registerPathHandler("/csp", (req, res) => {
+ // Inserting the ";" just in case something somehow merges the headers by ","
+ // (e.g. to "bla,; default-src http://yes http://maybe ;,bla").
+ // This ensures that the server-set "default-src" CSP is not somehow mangled.
+ res.setHeader(
+ "Content-Security-Policy",
+ "; default-src http://yes http://maybe ;"
+ );
+});
+
+server.registerPathHandler("/responseheadersFixture", (req, res) => {
+ res.setHeader("a", "server_a");
+ res.setHeader("b", "server_b");
+ res.setHeader("c", "server_c");
+ res.setHeader("d", "server_d");
+ res.setHeader("e", "server_e");
+ // www-authenticate and proxy-authenticate are among the few headers where
+ // the test server (httpd.js) allows multiple header lines instead of
+ // automatically concatenating them with ",":
+ // https://searchfox.org/mozilla-central/rev/a4a41aafa80bf38f6e456238a60781fed46f9d08/netwerk/test/httpserver/httpd.js#5280
+ res.setHeader("www-authenticate", "first_line");
+ res.setHeader("www-authenticate", "second_line", /* merge */ true);
+ res.setHeader("proxy-authenticate", "first_line");
+ res.setHeader("proxy-authenticate", "second_line", /* merge */ true);
+});
+
+server.registerPathHandler("/setcookie", (req, res) => {
+ // set-cookie is also allowed to span multiple lines.
+ res.setHeader("Set-Cookie", "food=yummy; max-age=999");
+ res.setHeader("Set-Cookie", "second=serving; max-age=999", /* merge */ true);
+ res.write(req.hasHeader("Cookie") ? req.getHeader("Cookie") : "");
+});
+server.registerPathHandler("/empty", (req, res) => {});
+
+add_setup(() => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+
+ // The restrictedDomains pref should be set early, because the pref is read
+ // only once (on first use) by WebExtensionPolicy::IsRestrictedURI.
+ Services.prefs.setCharPref(
+ "extensions.webextensions.restrictedDomains",
+ "restricted"
+ );
+});
+
+// This function is serialized and called in the context of the test extension's
+// background page. dnrTestUtils is passed to the background function.
+function makeDnrTestUtils() {
+ const dnrTestUtils = {};
+ async function fetchAsJson(url, options) {
+ let res = await fetch(url, options);
+ let txt = await res.text();
+ try {
+ return JSON.parse(txt);
+ } catch (e) {
+ return txt;
+ }
+ }
+ Object.assign(dnrTestUtils, {
+ fetchAsJson,
+ });
+ return dnrTestUtils;
+}
+
+async function runAsDNRExtension({
+ background,
+ manifest,
+ unloadTestAtEnd = true,
+}) {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${background})((${makeDnrTestUtils})())`,
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest"],
+ host_permissions: ["<all_urls>"],
+ granted_host_permissions: true,
+ ...manifest,
+ },
+ temporarilyInstalled: true, // <-- for granted_host_permissions
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ if (unloadTestAtEnd) {
+ await extension.unload();
+ }
+ return extension;
+}
+
+add_task(async function modifyHeaders_requestHeaders() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { fetchAsJson } = dnrTestUtils;
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { urlFilter: "set_twice" },
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [
+ { operation: "set", header: "a", value: "a-first" },
+ // second set should be ignored after set.
+ { operation: "set", header: "a", value: "a-second" },
+ ],
+ },
+ },
+ {
+ id: 2,
+ condition: { urlFilter: "set_and_remove" },
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [
+ { operation: "set", header: "b", value: "b-value" },
+ // remove should be ignored after set.
+ { operation: "remove", header: "b" },
+ ],
+ },
+ },
+ {
+ id: 3,
+ condition: { urlFilter: "remove_and_set" },
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [
+ { operation: "remove", header: "c" },
+ // set should be ignored after remove.
+ { operation: "set", header: "c", value: "c-value" },
+ // append should be ignored after remove.
+ { operation: "append", header: "c", value: "c-appended" },
+ ],
+ },
+ },
+ {
+ id: 4,
+ condition: { urlFilter: "remove_only" },
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [{ operation: "remove", header: "d" }],
+ },
+ },
+ {
+ id: 5,
+ condition: { urlFilter: "append_twice" },
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [
+ { operation: "append", header: "e", value: "e-first" },
+ { operation: "append", header: "e", value: "e-second" },
+ ],
+ },
+ },
+ {
+ id: 6,
+ condition: { urlFilter: "set_and_append" },
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [
+ { operation: "set", header: "f", value: "f-first" },
+ { operation: "append", header: "f", value: "f-second" },
+ ],
+ },
+ },
+ ],
+ });
+
+ browser.test.assertDeepEq(
+ { existing: "header" },
+ await fetchAsJson(
+ "http://dummy/echoheaders?not_matching_any_dnr_rule",
+ { headers: { existing: "header" } }
+ ),
+ "Sanity check: should echo original headers without matching DNR rules"
+ );
+
+ // Tests set_twice rule:
+
+ browser.test.assertDeepEq(
+ { a: "a-first" },
+ await fetchAsJson("http://dummy/echoheaders?set_twice"),
+ "only the first header should be used when set twice"
+ );
+ browser.test.assertDeepEq(
+ { a: "a-first" },
+ await fetchAsJson("http://dummy/echoheaders?set_twice", {
+ headers: { a: "original" },
+ }),
+ "original header should be overwritten by DNR"
+ );
+
+ // Tests set_and_remove rule:
+
+ browser.test.assertDeepEq(
+ { b: "b-value" },
+ await fetchAsJson("http://dummy/echoheaders?set_and_remove"),
+ "after setting a header, remove should be ignored"
+ );
+ browser.test.assertDeepEq(
+ { b: "b-value" },
+ await fetchAsJson("http://dummy/echoheaders?set_and_remove", {
+ headers: { b: "original" },
+ }),
+ "after overwriting a header, remove should be ignored"
+ );
+
+ // Tests remove_and_set rule:
+
+ browser.test.assertDeepEq(
+ { start: "START", end: "end" },
+ await fetchAsJson("http://dummy/echoheaders?remove_and_set", {
+ headers: { start: "START", c: "remove me", end: "end" },
+ }),
+ "after removing a header, remove should be ignored"
+ );
+ browser.test.assertDeepEq(
+ {},
+ await fetchAsJson("http://dummy/echoheaders?remove_and_set"),
+ "after a remove op (despite no existing header), set should be ignored"
+ );
+
+ // Tests remove_only rule:
+
+ browser.test.assertDeepEq(
+ {},
+ await fetchAsJson("http://dummy/echoheaders?remove_only", {
+ headers: { d: "remove me please" },
+ }),
+ "should remove header"
+ );
+
+ // Tests append_twice rule:
+
+ browser.test.assertDeepEq(
+ { e: "original, e-first, e-second" },
+ await fetchAsJson("http://dummy/echoheaders?append_twice", {
+ headers: { e: "original" },
+ }),
+ "should append headers"
+ );
+ browser.test.assertDeepEq(
+ { e: "e-first, e-second" },
+ await fetchAsJson("http://dummy/echoheaders?append_twice"),
+ "should append headers if there are no existing ones yet"
+ );
+
+ // Tests set_and_append rule:
+
+ browser.test.assertDeepEq(
+ { f: "f-first, f-second" },
+ await fetchAsJson("http://dummy/echoheaders?set_and_append", {
+ headers: { f: "original" },
+ }),
+ "should overwrite and append headers"
+ );
+
+ // All rules together:
+
+ browser.test.assertDeepEq(
+ {
+ a: "a-first",
+ b: "b-value",
+ e: "olde, e-first, e-second",
+ f: "f-first, f-second",
+ extra: "",
+ },
+ await fetchAsJson(
+ "http://dummy/echoheaders?set_twice,set_and_remove,remove_and_set,remove_only,append_twice,set_and_append",
+ {
+ headers: {
+ a: "olda",
+ b: "oldb",
+ c: "oldc",
+ d: "oldd",
+ e: "olde",
+ f: "oldf",
+ extra: "",
+ },
+ }
+ ),
+ "modifyHeaders actions from multiple rules should all apply"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// Host header is restricted, for details see bug 1467523.
+add_task(async function requestHeaders_set_host_header() {
+ async function background() {
+ const makeModifyHostRule = (id, urlFilter, value) => ({
+ id,
+ condition: { urlFilter },
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [{ operation: "set", header: "Host", value }],
+ },
+ });
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ makeModifyHostRule(1, "yes_host_permissions", "yes"),
+ makeModifyHostRule(2, "no_host_permissions", "no"),
+ makeModifyHostRule(3, "restricted_domain", "restricted"),
+ ],
+ });
+
+ browser.test.assertEq(
+ "yes",
+ await (await fetch("http://dummy/host?yes_host_permissions")).text(),
+ "Host header value allowed if extension has permission for new value"
+ );
+
+ browser.test.assertEq(
+ "dummy",
+ await (await fetch("http://dummy/host?no_host_permissions")).text(),
+ "Host header value ignored if extension misses permission for new value"
+ );
+
+ browser.test.assertEq(
+ "dummy",
+ await (await fetch("http://dummy/host?restricted_domain")).text(),
+ "Host header value ignored if new host is in restrictedDomains"
+ );
+
+ browser.test.notifyPass();
+ }
+ const { messages } = await promiseConsoleOutput(async () => {
+ await runAsDNRExtension({
+ manifest: {
+ // Note: host_permissions without "*://no/*".
+ host_permissions: ["*://dummy/*", "*://yes/*", "*://restricted/*"],
+ },
+ background,
+ });
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ {
+ message:
+ /Failed to apply modifyHeaders action to header "Host" \(DNR rule id 2 from ruleset "_session"\): Error: Unable to set host header, url missing from permissions\./,
+ },
+ {
+ message:
+ /Failed to apply modifyHeaders action to header "Host" \(DNR rule id 3 from ruleset "_session"\): Error: Unable to set host header to restricted url\./,
+ },
+ ],
+ });
+});
+
+add_task(async function requestHeaders_set_host_header_multiple_extensions() {
+ async function background() {
+ const hostHeaderValue = browser.runtime.getManifest().name;
+
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { resourceTypes: ["xmlhttprequest"] },
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [
+ { operation: "set", header: "Host", value: hostHeaderValue },
+ // Add a unique header for each request to verify that the
+ // extension can still modify other headers despite failure to
+ // modify the host header.
+ { operation: "set", header: hostHeaderValue, value: "setbydnr" },
+ ],
+ },
+ },
+ ],
+ });
+ browser.test.notifyPass();
+ }
+ // Precedence is in install order, most recent first.
+ // While this extension is permitted to change Host to "maybe", it has a lower
+ // precedence than extensionWithPermissionAndHigherPrecedence.
+ let extensionWithPermissionButLowerPrecedence = await runAsDNRExtension({
+ unloadTestAtEnd: false,
+ manifest: {
+ name: "maybe",
+ host_permissions: ["*://dummy/*", "*://maybe/*"],
+ },
+ background,
+ });
+ // This extension is permitted to change Host to "yes".
+ let extensionWithPermissionAndHigherPrecedence = await runAsDNRExtension({
+ unloadTestAtEnd: false,
+ manifest: { name: "yes", host_permissions: ["*://dummy/*", "*://yes/*"] },
+ background,
+ });
+ // While this extension has the highest precedence by install order, it does
+ // not have permission to change "Host" to "no".
+ let extensionWithoutPermissionForHostHeader = await runAsDNRExtension({
+ unloadTestAtEnd: false,
+ manifest: { name: "no", host_permissions: ["*://dummy/*"] },
+ background,
+ });
+
+ Assert.equal(
+ await ExtensionTestUtils.fetch("http://dummy/", "http://dummy/host"),
+ "yes",
+ "Host header changedby the most recently installed extension with the right permission"
+ );
+
+ const { messages, result } = await promiseConsoleOutput(() =>
+ ExtensionTestUtils.fetch("http://dummy/", "http://dummy/echoheaders")
+ );
+ Assert.equal(
+ result,
+ `{"referer":"http://dummy/","no":"setbydnr","yes":"setbydnr","maybe":"setbydnr"}`,
+ "Host header changedby the most recently installed extension with the right permission"
+ );
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ {
+ message:
+ /Failed to apply modifyHeaders action to header "Host" \(DNR rule id 1 from ruleset "_session"\): Error: Unable to set host header, url missing from permissions\./,
+ },
+ ],
+ });
+
+ await extensionWithPermissionButLowerPrecedence.unload();
+ await extensionWithPermissionAndHigherPrecedence.unload();
+ await extensionWithoutPermissionForHostHeader.unload();
+});
+
+add_task(async function modifyHeaders_responseHeaders() {
+ await runAsDNRExtension({
+ background: async () => {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { urlFilter: "/responseheadersFixture" },
+ action: {
+ type: "modifyHeaders",
+ responseHeaders: [
+ { operation: "set", header: "a", value: "a-first" },
+ // remove after set should be ignored:
+ { operation: "remove", header: "a" },
+ // Second set should be ignored:
+ { operation: "set", header: "a", value: "a-second" },
+ // But append is permitted:
+ { operation: "append", header: "a", value: "a-third" },
+ // Another append is allowed too:
+ { operation: "append", header: "a", value: "a-fourth" },
+ // An unrelated set is accepted:
+ { operation: "set", header: "b", value: "b-dnr" },
+ // An unrelated remove is also accepted:
+ { operation: "remove", header: "c" },
+ // An unrelated append is also accepted:
+ { operation: "append", header: "d", value: "d-dnr" },
+ // The server also sends the "e" header, we don't touch that.
+
+ // The server sends the www-authenticate header on two lines,
+ // which should be removed.
+ { operation: "remove", header: "www-authenticate" },
+ // The server also sends the proxy-authenticate header on two
+ // lines, but we don't touch that.
+ ],
+ },
+ },
+ ],
+ });
+
+ let { headers } = await fetch("http://dummy/responseheadersFixture");
+ browser.test.assertEq(
+ "a-first, a-third, a-fourth",
+ headers.get("a"),
+ "a set, ignored set + remove, 2x append"
+ );
+ browser.test.assertEq("b-dnr", headers.get("b"), "b set");
+ browser.test.assertEq(null, headers.get("c"), "c removed");
+ browser.test.assertEq("server_d, d-dnr", headers.get("d"), "d appended");
+ browser.test.assertEq("server_e", headers.get("e"), "e not touched");
+ browser.test.assertEq(
+ null,
+ headers.get("www-authenticate"),
+ "multi-line www-authenticate header removed"
+ );
+
+ // Multi-line http headers cannot be tested through fetch/Headers. This is
+ // a known limitation of that API, see e.g. note about Set-Cookie in the
+ // fetch spec - https://fetch.spec.whatwg.org/#headers-class
+ browser.test.assertEq(
+ null, // Note: null because Headers does not see multi-line headers.
+ headers.get("proxy-authenticate"),
+ "multi-line proxy-authenticate header kept (but fetch cannot see it)"
+ );
+
+ // XMLHttpRequest can return multi-line values, so we use that instead.
+ const xhr = new XMLHttpRequest();
+ await new Promise(r => {
+ xhr.onloadend = r;
+ xhr.open("GET", "http://dummy/responseheadersFixture?xhr");
+ xhr.send();
+ });
+ browser.test.assertEq(
+ null,
+ xhr.getResponseHeader("www-authenticate"),
+ "multi-line www-authenticate header removed"
+ );
+ browser.test.assertEq(
+ "first_line\nsecond_line",
+ xhr.getResponseHeader("proxy-authenticate"),
+ "multi-line proxy-authenticate header kept (seen through XHR)"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function responseHeaders_set_content_security_policy_header() {
+ let extension = await runAsDNRExtension({
+ unloadTestAtEnd: false,
+ background: async () => {
+ // By default, a DNR condition excludes the main frame. But to verify that
+ // the CSP works, we have to modify the CSP header of a document request.
+ const resourceTypes = ["main_frame"];
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { resourceTypes, urlFilter: "/csp?remove" },
+ action: {
+ type: "modifyHeaders",
+ responseHeaders: [
+ { operation: "remove", header: "Content-Security-Policy" },
+ ],
+ },
+ },
+ {
+ id: 2,
+ condition: { resourceTypes, urlFilter: "/csp?append_to_server" },
+ action: {
+ type: "modifyHeaders",
+ responseHeaders: [
+ {
+ operation: "append",
+ header: "Content-Security-Policy",
+ // Server has "default-src http://yes http://maybe". When
+ // multiple CSP header lines are present, all policies should
+ // be enforced, thus "http://no" below should be ignored, and
+ // the "http://maybe" from the server be ignored.
+ value: "connect-src http://YES http://not-maybe http://no",
+ },
+ ],
+ },
+ },
+ {
+ id: 3,
+ condition: { resourceTypes, urlFilter: "/csp?set_and_append" },
+ action: {
+ type: "modifyHeaders",
+ responseHeaders: [
+ {
+ operation: "set",
+ header: "Content-Security-Policy",
+ value: "connect-src 1-of-2 http://yes http://maybe",
+ },
+ {
+ operation: "append",
+ header: "Content-Security-Policy",
+ value: "connect-src 2-of-2 http://yes",
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+
+ async function testFetchAndCSP(url) {
+ info(`testFetchAndCSP: ${url}`);
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+ let cspTestResults = await contentPage.spawn([], async () => {
+ const { document } = content;
+ async function doFetchAndCheckCSP(url) {
+ const cspTestResult = { url, violatedCSP: [] };
+ let cspListener;
+ let cspEventPromise = new Promise(resolve => {
+ cspListener = e => {
+ cspTestResult.violatedCSP.push(e.originalPolicy);
+ // A CSP violation results in an event for each violated policy,
+ // dispatched after each other. Post a macrotask to ensure that all
+ // violations are caught.
+ content.setTimeout(resolve, 0);
+ };
+ });
+ document.addEventListener("securitypolicyviolation", cspListener);
+ try {
+ let res = await content.fetch(url);
+ let responseText = await res.text();
+ if (responseText !== "EXPECTED_RESPONSE_FOR /csp test") {
+ cspTestResult.unexpectedResponseText = responseText;
+ }
+ // No await cspEventPromise, because we are not expecting any errors.
+ // If there was any CSP violation, we would have ended in catch.
+ } catch (e) {
+ dump(`\nFailed to fetch ${url}, waiting for CSP report/event.\n`);
+ await cspEventPromise;
+ }
+ document.removeEventListener("securitypolicyviolation", cspListener);
+ return cspTestResult;
+ }
+
+ return {
+ yes: await doFetchAndCheckCSP("http://yes/csptest"),
+ maybe: await doFetchAndCheckCSP("http://maybe/csptest"),
+ no: await doFetchAndCheckCSP("http://no/csptest"),
+ };
+ });
+ await contentPage.close();
+ return cspTestResults;
+ }
+
+ // Note: this is derived from the server's policy. The server sends a bit more
+ // in the Content-Security-Policy header (i.e. ";"), but the normalized form
+ // is as follows.
+ const SERVER_DEFAULT_CSP = "default-src http://yes http://maybe";
+
+ // First, sanity check:
+ Assert.deepEqual(
+ await testFetchAndCSP("http://dummy/csp"),
+ {
+ yes: { url: "http://yes/csptest", violatedCSP: [] },
+ maybe: { url: "http://maybe/csptest", violatedCSP: [] },
+ no: { url: "http://no/csptest", violatedCSP: [SERVER_DEFAULT_CSP] },
+ },
+ "Sanity check: Server sends CSP that only allows requests to http://yes."
+ );
+
+ Assert.deepEqual(
+ await testFetchAndCSP("http://dummy/csp?remove"),
+ {
+ yes: { url: "http://yes/csptest", violatedCSP: [] },
+ maybe: { url: "http://maybe/csptest", violatedCSP: [] },
+ no: { url: "http://no/csptest", violatedCSP: [] },
+ },
+ "DNR remove CSP: results in no requests blocked by CSP"
+ );
+
+ Assert.deepEqual(
+ {
+ yes: { url: "http://yes/csptest", violatedCSP: [] },
+ maybe: {
+ url: "http://maybe/csptest",
+ violatedCSP: [
+ // This value was appended by DNR (with upper-case "http://YES", but
+ // the normalized form should be lowercase "http://yes"), and notably
+ // the "yes" request above should still pass.
+ "connect-src http://yes http://not-maybe http://no",
+ ],
+ },
+ no: { url: "http://no/csptest", violatedCSP: [SERVER_DEFAULT_CSP] },
+ },
+ await testFetchAndCSP("http://dummy/csp?append_to_server"),
+ "DNR append CSP: should enforce CSP of server and DNR"
+ );
+
+ Assert.deepEqual(
+ await testFetchAndCSP("http://dummy/csp?set_and_append"),
+ {
+ yes: { url: "http://yes/csptest", violatedCSP: [] },
+ maybe: {
+ url: "http://maybe/csptest",
+ violatedCSP: [
+ // Note: "http://" is before 2-of-2 due to bug 1804145.
+ "connect-src http://2-of-2 http://yes",
+ ],
+ },
+ no: {
+ url: "http://no/csptest",
+ violatedCSP: [
+ // Note: "http://" is before 1-of-2 and 2-of-2 due to bug 1804145.
+ "connect-src http://1-of-2 http://yes http://maybe",
+ "connect-src http://2-of-2 http://yes",
+ ],
+ },
+ },
+ "DNR set + append CSP: should enforce both CSPs from DNR"
+ );
+
+ await extension.unload();
+});
+
+// Set-Cookie is special because it may span multiple lines. This test tests a
+// combination of requestHeaders/responseHeaders and that the DNR-set cookies
+// are really working, i.e. visible to server and/or modifying the client's
+// cookie jar.
+add_task(async function requestHeaders_and_responseHeaders_cookies() {
+ let extension = await runAsDNRExtension({
+ unloadTestAtEnd: false,
+ background: async () => {
+ // By default, a DNR condition excludes the main frame. But this test uses
+ // a document load to verify that cookie header modifications (if any) are
+ // reflected in document.cookie.
+ const resourceTypes = ["main_frame"];
+
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { resourceTypes, urlFilter: "dnr_resp_drop_cookie" },
+ action: {
+ type: "modifyHeaders",
+ responseHeaders: [{ operation: "remove", header: "set-cookie" }],
+ },
+ },
+ {
+ id: 2,
+ condition: { resourceTypes, urlFilter: "dnr_resp_set_cookie" },
+ action: {
+ type: "modifyHeaders",
+ responseHeaders: [
+ {
+ operation: "set",
+ header: "set-cookie",
+ value: "dnr_res=set; max-age=999",
+ },
+ ],
+ },
+ },
+ {
+ id: 3,
+ condition: { resourceTypes, urlFilter: "dnr_set_cookie_to_req" },
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [
+ { operation: "set", header: "cookie", value: "dnr_req=1" },
+ ],
+ },
+ },
+ {
+ id: 4,
+ condition: {
+ resourceTypes,
+ urlFilter: "dnr_append_cookie_to_req_and_res",
+ },
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [
+ // Just for extra coverage, mix upper/lower case.
+ { operation: "append", header: "Cookie", value: "DNR_APP=1" },
+ { operation: "append", header: "cookie", value: "DNR_app=2" },
+ ],
+ responseHeaders: [
+ {
+ operation: "append",
+ header: "set-cookie",
+ value: "dnr_res=appended; max-age=999",
+ },
+ ],
+ },
+ },
+ {
+ id: 5,
+ condition: {
+ resourceTypes,
+ urlFilter: "dnr_set_server_cookies_expired",
+ },
+ action: {
+ type: "modifyHeaders",
+ responseHeaders: [
+ {
+ operation: "set",
+ header: "set-cookie",
+ value: "food=deletedbydnr; second=deletedbydnr; max-age=-1",
+ },
+ {
+ operation: "append",
+ header: "set-cookie",
+ value: "second=deletedbydnr; max-age=-1",
+ },
+ ],
+ },
+ },
+ {
+ id: 6,
+ condition: {
+ resourceTypes,
+ urlFilter: "dnr_resp_append_expired_cookie",
+ },
+ action: {
+ type: "modifyHeaders",
+ responseHeaders: [
+ {
+ operation: "append",
+ header: "set-cookie",
+ value: "dnr_res=deleteme; max-age=-1",
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+
+ async function loadPageAndGetCookies(pathAndQuery) {
+ const url = `http://cookietest${pathAndQuery}`;
+ info(`loadPageAndGetCookies: ${url}`);
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+ let res = await contentPage.spawn([], () => {
+ const { document } = content;
+ const sortCookies = s => s.split("; ").sort().join("; ");
+ return {
+ // Server at /setcookie echos value of Cookie request header.
+ serverSeenCookies: sortCookies(document.body.textContent),
+ clientSeenCookies: sortCookies(document.cookie),
+ };
+ });
+ await contentPage.close();
+ return res;
+ }
+
+ Assert.deepEqual(
+ { serverSeenCookies: "", clientSeenCookies: "" },
+ await loadPageAndGetCookies("/setcookie?dnr_resp_drop_cookie"),
+ "Set-Cookie from server ignored due to DNR (remove Set-Cookie)"
+ );
+ Assert.deepEqual(
+ {
+ serverSeenCookies: "",
+ clientSeenCookies: "dnr_res=set",
+ },
+ await loadPageAndGetCookies("/setcookie?dnr_resp_set_cookie"),
+ "Set-Cookie from server overwritten by DNR (set Set-Cookie)"
+ );
+ Assert.deepEqual(
+ {
+ // No cookies from previous request + request-specific cookie from DNR.
+ serverSeenCookies: "dnr_req=1",
+ // Notably, "dnr_req=1" should be missing from clientSeenCookies, because
+ // it is added in the request, so only seen by the server. Only cookies
+ // set by Set-Cookie are persisted/seen by the client.
+ clientSeenCookies: "dnr_res=set; food=yummy; second=serving",
+ },
+ await loadPageAndGetCookies("/setcookie?dnr_set_cookie_to_req"),
+ "Cookie req header from DNR, shadows existing client-generated Cookie header"
+ );
+ Assert.deepEqual(
+ {
+ // Cookies from previous request + request-specific cookies from DNR.
+ serverSeenCookies:
+ "DNR_APP=1; DNR_app=2; dnr_res=set; food=yummy; second=serving",
+ // NDR_APP and DNR_app are notably missing. dnr_res was modified by DNR,
+ // because an appended cookie with the same name overwrites existing one.
+ clientSeenCookies: "dnr_res=appended; food=yummy; second=serving",
+ },
+ await loadPageAndGetCookies("/setcookie?dnr_append_cookie_to_req_and_res"),
+ "Cookie req header from DNR, merged with existing client cookies; Set-Cookie from server merged with DNR (append Set-Cookie)"
+ );
+ Assert.deepEqual(
+ {
+ // Cookies from previous request (not changed by DNR):
+ serverSeenCookies: "dnr_res=appended; food=yummy; second=serving",
+ // Server cookies removed, only previously added DNR cookie sticks:
+ clientSeenCookies: "dnr_res=appended",
+ },
+ await loadPageAndGetCookies("/setcookie?dnr_set_server_cookies_expired"),
+ "Set-Cookie from server expired by DNR (set Set-Cookie + expire server cookies)"
+ );
+ Assert.deepEqual(
+ {
+ // Cookies from previous request (not changed by DNR):
+ serverSeenCookies: "dnr_res=appended",
+ // Cookies from server; because we used "append", they should merge, and
+ // expire the previous DNR cookie, and create the server-set cookies.
+ clientSeenCookies: "food=yummy; second=serving",
+ },
+ await loadPageAndGetCookies("/setcookie?dnr_resp_append_expired_cookie"),
+ "Set-Cookie from server merged with DNR (append Set-Cookie + expire dnr_res)"
+ );
+ // We've already tested dnr_set_server_cookies_expired before, now we're just
+ // cleaning up.
+ Assert.deepEqual(
+ {
+ serverSeenCookies: "food=yummy; second=serving",
+ clientSeenCookies: "",
+ },
+ await loadPageAndGetCookies("/setcookie?dnr_set_server_cookies_expired"),
+ "DNR cleared remaining cookies (set Set-Cookie + expire server cookies)"
+ );
+
+ await extension.unload();
+});
+
+// This test confirms the effective modifyHeaders actions if multiple extensions
+// have matching modifyHeaders rules. Only one extension is allowed to modify
+// headers.
+add_task(async function modifyHeaders_multiple_extensions() {
+ async function background() {
+ const extName = browser.runtime.getManifest().name;
+ function makeModifyHeadersRule(id, operation, headerName) {
+ const urlFilter = `${extName}_${operation}_${headerName}`;
+ let value;
+ if (operation !== "remove") {
+ // Use the urlFilter as value so that it's obvious which rule added it.
+ value = urlFilter;
+ }
+ return {
+ id,
+ condition: { urlFilter },
+ action: {
+ type: "modifyHeaders",
+ // As the logic of responseHeaders and requestHeaders is shared, it
+ // suffices to only check responseHeaders here.
+ responseHeaders: [{ operation, header: headerName, value }],
+ },
+ };
+ }
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ makeModifyHeadersRule(1, "set", "a"),
+ makeModifyHeadersRule(2, "remove", "a"),
+ makeModifyHeadersRule(3, "append", "a"),
+ makeModifyHeadersRule(4, "set", "b"),
+ makeModifyHeadersRule(5, "remove", "b"),
+ makeModifyHeadersRule(6, "append", "b"),
+ ],
+ });
+ browser.test.notifyPass();
+ }
+
+ // Cross-extension rule precedence is in the order of extension installation.
+ const prioTwoExtension = await runAsDNRExtension({
+ manifest: { name: "prioTwo" },
+ background,
+ unloadTestAtEnd: false,
+ });
+ const prioOneExtension = await runAsDNRExtension({
+ manifest: { name: "prioOne" },
+ background,
+ unloadTestAtEnd: false,
+ });
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://dummy/empty"
+ );
+ async function checkHeaderActionResult(query, expectedHeaders, description) {
+ const url = `/responseheadersFixture?${query}`;
+ const result = await contentPage.spawn([url], async url => {
+ const res = await content.fetch(url);
+ return {
+ a: res.headers.get("a"),
+ b: res.headers.get("b"),
+ };
+ });
+ Assert.deepEqual(
+ result,
+ expectedHeaders,
+ `${description} - Expected headers for ${url}`
+ );
+ }
+
+ await checkHeaderActionResult(
+ "",
+ {
+ a: "server_a",
+ b: "server_b",
+ },
+ "Sanity check: headers should be unmodified without matching DNR rules"
+ );
+
+ // First: verify that "set" is only permitted if there are no other extensions
+ // that have already modified the header. Note that this requirement already
+ // holds for actions within one extension, so they should still be enforced
+ // for modifyHeaders actions from multiple extensions.
+ await checkHeaderActionResult(
+ "prioOne_set_a,prioTwo_set_a,prioTwo_set_b",
+ {
+ a: "prioOne_set_a",
+ b: "prioTwo_set_b",
+ },
+ "set should only be allowed if no other extension has set a header"
+ );
+ await checkHeaderActionResult(
+ "prioOne_remove_a,prioTwo_set_a,prioTwo_set_b",
+ {
+ a: null,
+ b: "prioTwo_set_b",
+ },
+ "set should only be allowed if no other extension has removed the header"
+ );
+ await checkHeaderActionResult(
+ "prioOne_append_a,prioTwo_set_a,prioTwo_set_b",
+ {
+ a: "server_a, prioOne_append_a",
+ b: "prioTwo_set_b",
+ },
+ "set should only be allowed if no other extension has appended the header"
+ );
+
+ // The "remove" operation is not logically conflicting, let's confirm that it
+ // works as usual.
+ await checkHeaderActionResult(
+ "prioOne_remove_a,prioTwo_remove_a,prioTwo_remove_b",
+ {
+ a: null,
+ b: null,
+ },
+ "remove should work, regardless of the number of extensions that use it"
+ );
+
+ // While an extension can specify multiple "append" operations, only one
+ // extension should be able to use it. Another extension is still allowed to
+ // modify an unrelated, not-yet-modified header.
+ await checkHeaderActionResult(
+ "prioOne_append_a,prioTwo_append_a,prioTwo_append_b",
+ {
+ a: "server_a, prioOne_append_a",
+ b: "server_b, prioTwo_append_b",
+ },
+ "Only one extension may modify a specific header"
+ );
+
+ await contentPage.close();
+ await prioOneExtension.unload();
+ await prioTwoExtension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_private_browsing.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_private_browsing.js
new file mode 100644
index 0000000000..e9e7f90b01
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_private_browsing.js
@@ -0,0 +1,130 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerPathHandler("/", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.setHeader("Access-Control-Max-Age", "0");
+});
+
+add_setup(() => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+});
+
+async function startDNRExtension({ privateBrowsingAllowed }) {
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: privateBrowsingAllowed ? "spanning" : undefined,
+ async background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [{ id: 1, condition: {}, action: { type: "block" } }],
+ });
+ browser.test.sendMessage("dnr_registered");
+ },
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest"],
+ browser_specific_settings: { gecko: { id: "@dnr-ext" } },
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("dnr_registered");
+ return extension;
+}
+
+async function testMatchedByDNR(privateBrowsing) {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/?page",
+ { privateBrowsing }
+ );
+ let wasRequestBlocked = await contentPage.legacySpawn(null, async () => {
+ try {
+ await content.fetch("http://example.com/?fetch");
+ return false;
+ } catch (e) {
+ // Request blocked by DNR rule from startDNRExtension().
+ return true;
+ }
+ });
+ await contentPage.close();
+ return wasRequestBlocked;
+}
+
+add_task(async function private_browsing_not_allowed_by_default() {
+ let extension = await startDNRExtension({ privateBrowsingAllowed: false });
+ Assert.equal(
+ await testMatchedByDNR(false),
+ true,
+ "DNR applies to non-private browsing requests by default"
+ );
+ Assert.equal(
+ await testMatchedByDNR(true),
+ false,
+ "DNR not applied to private browsing requests by default"
+ );
+ await extension.unload();
+});
+
+add_task(async function private_browsing_allowed() {
+ let extension = await startDNRExtension({ privateBrowsingAllowed: true });
+ Assert.equal(
+ await testMatchedByDNR(false),
+ true,
+ "DNR applies to non-private requests regardless of privateBrowsingAllowed"
+ );
+ Assert.equal(
+ await testMatchedByDNR(true),
+ true,
+ "DNR applied to private browsing requests when privateBrowsingAllowed"
+ );
+ await extension.unload();
+});
+
+add_task(
+ { pref_set: [["extensions.dnr.feedback", true]] },
+ async function testMatchOutcome_unaffected_by_privateBrowsing() {
+ let extensionWithoutPrivateBrowsingAllowed = await startDNRExtension({});
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ },
+ files: {
+ "page.html": `<!DOCTYPE html><script src="page.js"></script>`,
+ "page.js": async () => {
+ browser.test.assertTrue(
+ browser.extension.inIncognitoContext,
+ "Extension page is opened in a private browsing context"
+ );
+ browser.test.assertDeepEq(
+ {
+ matchedRules: [
+ { ruleId: 1, rulesetId: "_session", extensionId: "@dnr-ext" },
+ ],
+ },
+ // testMatchOutcome does not offer a way to specify the private
+ // browsing mode of a request. Confirm that testMatchOutcome always
+ // simulates requests in normal private browsing mode, even if the
+ // testMatchOutcome method itself is called from an extension page
+ // in private browsing mode.
+ await browser.declarativeNetRequest.testMatchOutcome(
+ { url: "http://example.com/?simulated_request", type: "image" },
+ { includeOtherExtensions: true }
+ ),
+ "testMatchOutcome includes DNR from extensions without pbm access"
+ );
+ browser.test.sendMessage("done");
+ },
+ },
+ });
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/page.html`,
+ { privateBrowsing: true }
+ );
+ await extension.awaitMessage("done");
+ await contentPage.close();
+ await extension.unload();
+ await extensionWithoutPrivateBrowsingAllowed.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js
new file mode 100644
index 0000000000..de01169dea
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js
@@ -0,0 +1,723 @@
+"use strict";
+
+// The validate_action_redirect_transform task of test_ext_dnr_session_rules.js
+// confirms that redirect transform rules meet some minimum bar of validation.
+// Despite passing validation, there are still interesting cases to explore,
+// ranging from verifying that special characters appear as expected, to
+// verifying that an invalid URL (e.g. too long after the transform) is handled
+// reasonably well.
+
+add_setup(() => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+
+ // Allow navigation to URLs with embedded credentials, without prompt.
+ Services.prefs.setBoolPref("network.auth.confirmAuth.enabled", false);
+});
+
+const server = createHttpServer({
+ hosts: ["from", "dest", "127.0.0.127", "[::1]", "xn--stra-yna.de", "fqdn."],
+});
+server.identity.add("http", "dest", 443); // test_redirect_transform_port
+server.identity.add("http", "dest", 700); // test_redirect_transform_port
+server.identity.add("http", "dest", 777); // Dummy port in test cases.
+
+server.registerPrefixHandler("/", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.write("GOOD_RESPONSE");
+});
+
+// This function is serialized and called in the context of the test extension's
+// background page. dnrTestUtils is passed to the background function.
+function makeDnrTestUtils() {
+ const dnrTestUtils = {};
+ const dnr = browser.declarativeNetRequest;
+ function makeRedirectTransformRule(transform) {
+ return {
+ id: 1,
+ condition: { requestDomains: ["from"] },
+ action: {
+ type: "redirect",
+ // redirect to "dest" by default, different from "from", to avoid an
+ // infinite redirect loop.
+ redirect: { transform: { host: "dest", ...transform } },
+ },
+ };
+ }
+ async function setRedirectTransform(transform) {
+ await dnr.updateSessionRules({
+ removeRuleIds: [1],
+ addRules: [makeRedirectTransformRule(transform)],
+ });
+ }
+ // testFetch is simple/fast, but cannot always be used:
+ // - when the request URL contains embedded credentials.
+ // - when the final URL is supposed to contain a reference fragment.
+ async function testFetch(from, to, description) {
+ let res = await fetch(from);
+ browser.test.assertEq(to, res.url, description);
+ browser.test.assertEq("GOOD_RESPONSE", await res.text(), "expected body");
+ }
+ // testNavigate is the slower, complex version of testFetch. It should be
+ // used in tests where the username, password or fragment components of a URL
+ // are significant.
+ async function testNavigate(from, to, description) {
+ let resultPromise = new Promise(resolve => {
+ browser.test.onMessage.addListener(function listener(msg, result) {
+ if (msg === "test_navigate_result") {
+ browser.test.onMessage.removeListener(listener);
+ // resolve only resolves on the first call, which is ideal because
+ // browser.test.onMessage.removeListener does not work (bug 1428213).
+ resolve(result);
+ }
+ });
+ });
+ browser.test.sendMessage("test_navigate", from);
+ browser.test.assertDeepEq({ from, to }, await resultPromise, description);
+ }
+ Object.assign(dnrTestUtils, {
+ makeRedirectTransformRule,
+ setRedirectTransform,
+ testFetch,
+ testNavigate,
+ });
+ return dnrTestUtils;
+}
+
+async function runAsDNRExtension({ background, manifest }) {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${background})((${makeDnrTestUtils})())`,
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest"],
+ host_permissions: ["<all_urls>"],
+ granted_host_permissions: true,
+ web_accessible_resources: [
+ { resources: ["war.txt"], matches: ["http://from/*"] },
+ ],
+ ...manifest,
+ },
+ temporarilyInstalled: true, // <-- for granted_host_permissions
+ files: {
+ "war.txt": "GOOD_RESPONSE",
+ "nowar.txt": "nowar.txt is not in web_accessible_resources",
+ },
+ });
+ extension.onMessage("test_navigate", async url => {
+ // The DNR rule does not redirect the main frame.
+ let contentPage = await ExtensionTestUtils.loadContentPage("http://from/");
+ info(`Loading ${url}`);
+ await contentPage.spawn([url], async url => {
+ let { document } = this.content;
+ let frame = document.createElement("iframe");
+ frame.src = url;
+ await new Promise(resolve => {
+ frame.onload = resolve;
+ document.body.appendChild(frame);
+ });
+ });
+ let finalURL = contentPage.browsingContext.children[0].currentURI.spec;
+ await contentPage.close();
+ extension.sendMessage("test_navigate_result", { from: url, to: finalURL });
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+add_task(async function test_redirect_transform_all_at_once() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils;
+
+ await setRedirectTransform({
+ scheme: "http",
+ username: "a",
+ password: "b",
+ host: "dest",
+ port: "777",
+ path: "/d",
+ query: "?e",
+ queryTransform: null,
+ fragment: "#f",
+ });
+ await testFetch(
+ "https://from",
+ "http://a:b@dest:777/d?e", // note: fetch cannot see '#f'.
+ "Adds components to minimal URL (fetch)"
+ );
+ await testNavigate(
+ "https://from",
+ "http://a:b@dest:777/d?e#f",
+ "Adds components to minimal URL (navigation)"
+ );
+
+ await browser.test.assertRejects(
+ testFetch("https://user:pass@from:777/path?query#ref"),
+ "Window.fetch: https://user:pass@from:777/path?query#ref is an url with embedded credentials.",
+ "fetch does not work with embedded credentials"
+ );
+ await testNavigate(
+ "https://user:pass@from:777/path?query#ref",
+ "http://a:b@dest:777/d?e#f",
+ "Replaces all components in existing URL (navigation)"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function test_redirect_transform_scheme() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils;
+
+ await setRedirectTransform({ scheme: "http" });
+ await testFetch("https://from/", "http://dest/", "scheme change");
+ await testNavigate(
+ "https://user:pass@from:777/path?query#ref",
+ "http://user:pass@dest:777/path?query#ref",
+ "scheme change in complex URL with embedded credentials"
+ );
+
+ await setRedirectTransform({
+ scheme: "moz-extension",
+ host: location.hostname,
+ });
+ await testFetch(
+ "http://from/war.txt",
+ browser.runtime.getURL("war.txt"),
+ "Scheme change to moz-extension:-URL"
+ );
+ await testNavigate(
+ "http://from/war.txt",
+ browser.runtime.getURL("war.txt"),
+ "Scheme change to moz-extension:-URL (navigation)"
+ );
+ // While the initiator (extension) would be allowed to read the resource
+ // due to it being same-origin, the pre-redirect URL (http://from) is not
+ // matching web_accessible_resources[].matches, so the load is rejected.
+ // This scenario is also tested in test_ext_dnr_without_webrequest.js, at
+ // the redirect_request_with_dnr_to_extensionPath task.
+ await browser.test.assertRejects(
+ testFetch("http://from/nowar.txt"),
+ "NetworkError when attempting to fetch resource.",
+ "Cannot load redirect to moz-extension: not in web_accessible_resources"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function test_redirect_transform_username() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils;
+
+ await setRedirectTransform({ username: "" });
+ await testNavigate(
+ "http://user:pass@from:777/path?query#ref",
+ "http://:pass@dest:777/path?query#ref",
+ "username cleared"
+ );
+
+ await setRedirectTransform({ username: "new" });
+ // Cannot pass credentials to fetch, but can read from response.url:
+ await testFetch("http://from/", "http://new@dest/", "username added");
+ await testNavigate("http://from/", "http://new@dest/", "username added");
+ await testNavigate(
+ "http://user:pass@from:777/path?query#ref",
+ "http://new:pass@dest:777/path?query#ref",
+ "username changed"
+ );
+
+ await setRedirectTransform({ username: "new User:name@%%20/" });
+ await testNavigate(
+ "http://user:pass@from:777/path?query#ref",
+ "http://new%20User%3Aname%40%%20%2F:pass@dest:777/path?query#ref",
+ "username changed to complex value"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function test_redirect_transform_password() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils;
+
+ await setRedirectTransform({ password: "" });
+ await testNavigate(
+ "http://user:pass@from:777/path?query#ref",
+ "http://user@dest:777/path?query#ref",
+ "password cleared"
+ );
+
+ await setRedirectTransform({ password: "new" });
+ // Cannot pass credentials to fetch, but can read from response.url:
+ await testFetch("http://from/", "http://:new@dest/", "password added");
+ await testNavigate("http://from/", "http://:new@dest/", "password added");
+ await testNavigate(
+ "http://user:pass@from:777/path?query#ref",
+ "http://user:new@dest:777/path?query#ref",
+ "password changed"
+ );
+
+ await setRedirectTransform({ password: "new Pass:@%%20/" });
+ await testNavigate(
+ "http://user:pass@from:777/path?query#ref",
+ "http://user:new%20Pass%3A%40%%20%2F@dest:777/path?query#ref",
+ "password changed to complex value"
+ );
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function test_redirect_transform_host() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils;
+
+ await setRedirectTransform({ host: "dest" });
+ await testFetch(
+ "http://from:777/path?query",
+ "http://dest:777/path?query",
+ "host changed"
+ );
+ await testNavigate(
+ "http://user:pass@from:777/path?query#ref",
+ "http://user:pass@dest:777/path?query#ref",
+ "host changed without affecting embedded credentials"
+ );
+
+ await setRedirectTransform({ host: "DEST" });
+ await testFetch(
+ "http://from/",
+ "http://dest/",
+ "host changed (non-canonical, upper case)"
+ );
+
+ await setRedirectTransform({ host: "%44%65%73%54" }); // "DesT", escaped.
+ await testFetch(
+ "http://from:777/",
+ "http://dest:777/",
+ "host changed (non-canonical, percent-escaped)"
+ );
+
+ await setRedirectTransform({ host: "127.0.0.127" });
+ await testFetch(
+ "http://from/",
+ "http://127.0.0.127/",
+ "host change to IPv4"
+ );
+
+ await setRedirectTransform({ host: "[::1]" });
+ await testFetch("http://from/", "http://[::1]/", "host change to IPv6");
+
+ await setRedirectTransform({ host: "xn--stra-yna.de" });
+ await testFetch(
+ "http://from/",
+ "http://xn--stra-yna.de/",
+ "host change to IDN (internationalized domain name, in punycode)"
+ );
+
+ await setRedirectTransform({ host: "straß.de" });
+ await testFetch(
+ "http://from/",
+ "http://xn--stra-yna.de/",
+ "host change to IDN (not punycode-encoded)"
+ );
+
+ await setRedirectTransform({ host: "fqdn." });
+ await testFetch(
+ "http://from/",
+ "http://fqdn./",
+ "host change to FQDN (fully-qualified domain name)"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function test_redirect_transform_port() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils;
+
+ await setRedirectTransform({ port: "" });
+ await testFetch("http://from:777/", "http://dest/", "port cleared");
+ await testNavigate(
+ "http://user:pass@from:777/path?query#ref",
+ "http://user:pass@dest/path?query#ref",
+ "port cleared from URL with embedded credentials"
+ );
+
+ await setRedirectTransform({ port: "700" });
+ await testFetch("http://from/", "http://dest:700/", "port added");
+ await testFetch("http://from:777/", "http://dest:700/", "port changed");
+
+ // 0-padded should not be misinterpreted as an octal number.
+ await setRedirectTransform({ port: "0700" });
+ await testFetch(
+ "http://from:777/",
+ "http://dest:700/",
+ "port changed (non-canonical, 0-padded port)"
+ );
+
+ await setRedirectTransform({ port: "80" });
+ await testFetch(
+ "http://from:777/",
+ "http://dest/",
+ "port cleared if default protocol"
+ );
+
+ await setRedirectTransform({ scheme: "http", port: "443" });
+ await testFetch(
+ "https://from/",
+ "http://dest:443/",
+ "port added if new port is not default port of new protocol"
+ );
+
+ await setRedirectTransform({ scheme: "http", port: "80" });
+ await testFetch(
+ "https://from:777/",
+ "http://dest/",
+ "port cleared if new port is default port of new protocol"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function test_redirect_transform_path() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils;
+
+ await setRedirectTransform({ path: "" });
+ await testFetch("http://from/path", "http://dest/", "path cleared");
+ await testNavigate(
+ "http://user:pass@from:777/path?query#ref",
+ "http://user:pass@dest:777/?query#ref",
+ "path cleared from URL with embedded credentials"
+ );
+
+ await setRedirectTransform({ path: "/new" });
+ await testFetch("http://from/", "http://dest/new", "path added");
+ await testFetch("http://from/path", "http://dest/new", "path changed");
+
+ await setRedirectTransform({ path: "///" });
+ await testFetch("http://from/", "http://dest///", "path added (///)");
+
+ await setRedirectTransform({ path: "path" });
+ await testFetch(
+ "http://from/",
+ "http://dest/path",
+ "path added (non-canonical, missing slash)"
+ );
+
+ // " " -> "%20" (space)
+ // "\x00" -> "%00" (null byte)
+ // "<>" -> "%3C%3E" (URL encoding of angle brackets)
+ // "%", "%20", "%3A", "%3a" -> not changed (%-encoding kept as-is).
+ await setRedirectTransform({ path: "/Path_%_ _%20_?_#_\x00_<>_%3A%3a" });
+ await testFetch(
+ "http://from/",
+ "http://dest/Path_%_%20_%20_%3F_%23_%00_%3C%3E_%3A%3a",
+ "path added (non-canonical, partial percent encoding)"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function test_redirect_transform_query() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils;
+
+ await setRedirectTransform({ query: "" });
+ await testFetch("http://from/?query", "http://dest/", "query cleared");
+ await testNavigate(
+ "http://user:pass@from:777/path?query#ref",
+ "http://user:pass@dest:777/path#ref",
+ "query cleared from URL with embedded credentials"
+ );
+
+ await setRedirectTransform({ query: "?new" });
+ await testFetch("http://from/", "http://dest/?new", "query added");
+ await testFetch(
+ "http://from/?query",
+ "http://dest/?new",
+ "query changed"
+ );
+
+ await setRedirectTransform({ query: "?" });
+ await testFetch("http://from/", "http://dest/?", "query set to just '?'");
+
+ await setRedirectTransform({ query: "?Query_#_ _%20_%3a%3A_<>_\x00" });
+ await testFetch(
+ "http://from/",
+ "http://dest/?Query_%23_%20_%20_%3a%3A_%3C%3E_%00",
+ "query added (non-canonical, partial percent encoding)"
+ );
+
+ // Now rule.action.redirect.transform.queryTransform:
+ await setRedirectTransform({
+ queryTransform: {
+ removeParams: ["query"],
+ },
+ });
+ await testFetch(
+ "http://from/?query",
+ "http://dest/",
+ "queryTransform removed query"
+ );
+ await testFetch(
+ "http://from/?prefix&query&suffix",
+ "http://dest/?prefix&suffix",
+ "queryTransform removed part of query"
+ );
+ await testFetch(
+ "http://from/?query&aquery&queryb&query=withvalue&not=query&QUERY&",
+ "http://dest/?aquery&queryb&not=query&QUERY&",
+ "queryTransform removed all occurrences of 'query' key"
+ );
+ await testFetch(
+ "http://from/??query",
+ "http://dest/??query",
+ "queryTransform does not match param when it starts with '??'"
+ );
+
+ await setRedirectTransform({
+ queryTransform: {
+ removeParams: ["query"],
+ addOrReplaceParams: [{ key: "query", value: "newvalue" }],
+ },
+ });
+ await testFetch(
+ "http://from/",
+ "http://dest/?query=newvalue",
+ "queryTransform appended query despite new param being in removeParams"
+ );
+ await testFetch(
+ "http://from/?prefix&query&suffix",
+ "http://dest/?prefix&suffix&query=newvalue",
+ "queryTransform removed query, and appended new value"
+ );
+ await testFetch(
+ "http://from/??query",
+ "http://dest/??query&query=newvalue",
+ "queryTransform ignores existing param starting with '??', and appends"
+ );
+
+ await setRedirectTransform({
+ queryTransform: {
+ addOrReplaceParams: [{ key: "query", value: "newvalue" }],
+ },
+ });
+ await testFetch(
+ "http://from/",
+ "http://dest/?query=newvalue",
+ "queryTransform appended query"
+ );
+ await testFetch(
+ "http://from/?prefix&query=oldvalue&query=2&query=3",
+ "http://dest/?prefix&query=newvalue&query=2&query=3",
+ "queryTransform replaced the first occurrence and kept the others"
+ );
+
+ await setRedirectTransform({
+ queryTransform: {
+ addOrReplaceParams: [
+ { key: "r", value: "default" }, // default:false
+ { key: "r", value: "false", replaceOnly: false },
+ { key: "r", value: "true", replaceOnly: true },
+ { key: "r", value: "false2", replaceOnly: false },
+ { key: "r", value: "true2", replaceOnly: true },
+ ],
+ },
+ });
+ // r=true and r=true2 are missing because there are no matching "r".
+ await testFetch(
+ "http://from/",
+ "http://dest/?r=default&r=false&r=false2",
+ "queryTransform appends all except replaceOnly=true"
+ );
+ // r=true2 should be missing because there is no matching "r".
+ await testFetch(
+ "http://from/?r=1&r=2&r=3&___",
+ "http://dest/?r=default&r=false&r=true&___&r=false2",
+ "queryTransform replaced in order and ignores last replaceOnly=true"
+ );
+
+ await setRedirectTransform({
+ queryTransform: {
+ addOrReplaceParams: [
+ { key: "a", value: "appenda" },
+ { key: "b", value: "b1" },
+ { key: "c", value: "c1" },
+ { key: "c", value: "c2" },
+ { key: "c", value: "appendc" },
+ { key: "d", value: "d1" },
+ ],
+ },
+ });
+ // Test case has: b c c d.
+ // Rule only has: appenda b1 c2 appendc d1.
+ // Expected out : b1 c2 d1 appenda appendc.
+ await testFetch(
+ "http://from/?b=01&c=02&c=03&d=06",
+ "http://dest/?b=b1&c=c1&c=c2&d=d1&a=appenda&c=appendc",
+ "queryTransform replaces matched queries and appends the rest, in order"
+ );
+
+ await setRedirectTransform({
+ queryTransform: {
+ addOrReplaceParams: [{ key: "query", value: " _+_%00_#" }],
+ },
+ });
+ await testFetch(
+ "http://from/",
+ "http://dest/?query=+_%2B_%2500_%23",
+ "queryTransform urlencodes values"
+ );
+
+ // This part tests how param names with non-alphanumeric characters can be
+ // (and not be) matched and replaced. This follows Chrome's behavior, see
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1801870#c1
+ await setRedirectTransform({
+ queryTransform: {
+ removeParams: ["?x", "%3Fx", "&x", "%26x"],
+ addOrReplaceParams: [
+ // Internally interpreted as: %3Fp:
+ { key: "?p", value: "rawq", replaceOnly: true },
+ // Internally interpreted as: %253Fp:
+ { key: "%3Fp", value: "escape_upper_q", replaceOnly: true },
+ // Internally interpreted as: %253fp:
+ { key: "%3fp", value: "escape_lower_q", replaceOnly: true },
+ // Internally interpreted as: %26p:
+ { key: "&p", value: "rawa", replaceOnly: true },
+ // Internally interpreted as: %2526p:
+ { key: "%26p", value: "escape_a", replaceOnly: true },
+ ],
+ },
+ });
+ await testFetch(
+ "http://from/?x&x&?x",
+ "http://dest/?x&x&?x",
+ "queryTransform does not match the '?' or '&' separators"
+ );
+ await testFetch(
+ "http://from/??p&&p&?p",
+ "http://dest/??p&&p&?p",
+ "queryTransform cannot match literal '?p' because it is not urlencoded"
+ );
+ await testFetch(
+ "http://from/?%3Fp",
+ "http://dest/?%3Fp=rawq",
+ "queryTransform matches already-urlencoded '%3Fp' with raw '?p'"
+ );
+ await testFetch(
+ "http://from/?%3fp",
+ "http://dest/?%3fp",
+ "queryTransform cannot match non-canonical percent encoding (lowercase)"
+ );
+ await testFetch(
+ "http://from/?%253fp&%253Fp",
+ "http://dest/?%253fp=escape_lower_q&%253Fp=escape_upper_q",
+ "queryTransform matches double-urlencoded '?p' with single-encoded '?p'"
+ );
+ await testFetch(
+ "http://from/?%26p",
+ "http://dest/?%26p=rawa",
+ "queryTransform matches already-urlencoded '%26p' with raw '&p'"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function test_redirect_transform_fragment() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ // Note: not using testFetch because it cannot see fragment changes.
+ const { setRedirectTransform, testNavigate } = dnrTestUtils;
+
+ await setRedirectTransform({ fragment: "" });
+ await testNavigate(
+ "http://user:pass@from:777/path?query#ref",
+ "http://user:pass@dest:777/path?query",
+ "fragment cleared from URL with embedded credentials"
+ );
+
+ await setRedirectTransform({ fragment: "#new" });
+ await testNavigate("http://from/", "http://dest/#new", "fragment added");
+ await testNavigate(
+ "http://from/#ref",
+ "http://dest/#new",
+ "fragment changed"
+ );
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function test_redirect_transform_failed_at_runtime() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { setRedirectTransform } = dnrTestUtils;
+
+ // Maximum length of a UTL is 1048576 (network.standard-url.max-length).
+ const network_standard_url_max_length = 1048576;
+ // updateSessionRules does some validation on the limit (as seen by
+ // validate_action_redirect_transform in test_ext_dnr_session_rules.js),
+ // but it is still possible to pass validation and fail in practice when
+ // the existing URL + new component exceeds the limit.
+ const VERY_LONG_STRING = "x".repeat(network_standard_url_max_length - 20);
+
+ // Like testFetch, except truncates URLs in log messages to avoid logspam.
+ async function testFetchPossiblyLongUrl(from, to, body, description) {
+ let res = await fetch(from);
+ const shortx = s => s.replace(/x{10,}/g, xxx => `x{${xxx.length}}`);
+ // VERY_LONG_STRING consists of many 'X'. Shorten to avoid logspam.
+ browser.test.assertEq(shortx(to), shortx(res.url), description);
+ browser.test.assertEq(body, await res.text(), "expected body");
+ }
+
+ await setRedirectTransform({ query: "?" + VERY_LONG_STRING });
+ await testFetchPossiblyLongUrl(
+ "http://from/short",
+ `http://dest/short?${VERY_LONG_STRING}`,
+ // Somehow the httpd server raises NS_ERROR_MALFORMED_URI when it tries
+ // to use newURI to parse the received URL. But the server responding
+ // with that implies that the redirect was successful, so for the
+ // purpose of this test, that response is acceptable.
+ "Bad request\n",
+ "Can redirect to URL near (but not over) url max-length"
+ );
+
+ // This check confirms that not only does the request not redirect to
+ // an invalid URL, but also that the request does not somehow end up in
+ // an infinite redirect loop.
+ await testFetchPossiblyLongUrl(
+ "http://from/1234567890_1234567890",
+ "http://from/1234567890_1234567890",
+ "GOOD_RESPONSE",
+ "Redirect to URL over max length is ignored; request continues"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js
new file mode 100644
index 0000000000..0ee1bff815
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js
@@ -0,0 +1,590 @@
+"use strict";
+
+// This file provides test coverage for regexFilter and regexSubstitution.
+//
+// The validate_actions task of test_ext_dnr_session_rules.js checks that the
+// basic requirements of regexFilter + regexSubstitution are met.
+//
+// The match_regexFilter task of test_ext_dnr_testMatchOutcome.js verifies that
+// regexFilter is evaluated correctly in testMatchOutcome.
+//
+// The quota on regexFilter is verified in test_ext_dnr_regexFilter_limits.js.
+
+add_setup(() => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.feedback", true);
+});
+
+const server = createHttpServer({
+ hosts: ["example.com", "example-com", "from", "dest"],
+});
+server.registerPrefixHandler("/", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.write("GOOD_RESPONSE");
+});
+
+// This function is serialized and called in the context of the test extension's
+// background page. dnrTestUtils is passed to the background function.
+function makeDnrTestUtils() {
+ const dnrTestUtils = {};
+ const dnr = browser.declarativeNetRequest;
+
+ async function testFetch(from, to, description) {
+ let res = await fetch(from);
+ browser.test.assertEq(to, res.url, description);
+ browser.test.assertEq("GOOD_RESPONSE", await res.text(), "expected body");
+ }
+
+ async function _testRegexFilterOrRedirect({
+ description,
+ regexFilter,
+ isUrlFilterCaseSensitive,
+ expectedRedirectUrl = "http://dest/",
+ regexSubstitution = expectedRedirectUrl,
+ urlsMatching,
+ urlsNonMatching,
+ }) {
+ browser.test.log(`Test description: ${description}`);
+ await dnr.updateSessionRules({
+ addRules: [
+ {
+ id: 12345,
+ condition: { regexFilter, isUrlFilterCaseSensitive },
+ action: { type: "redirect", redirect: { regexSubstitution } },
+ },
+ ],
+ });
+ for (let url of urlsMatching) {
+ const description = `regexFilter ${regexFilter} should match: ${url}`;
+ await testFetch(url, expectedRedirectUrl, description);
+ }
+ for (let url of urlsNonMatching) {
+ const description = `regexFilter ${regexFilter} should not match: ${url}`;
+ let expectedUrl = new URL(url);
+ expectedUrl.hash = "";
+ await testFetch(url, expectedUrl.href, description);
+ }
+ await dnr.updateSessionRules({ removeRuleIds: [12345] });
+ }
+
+ async function testValidRegexFilter({
+ description,
+ regexFilter,
+ isUrlFilterCaseSensitive,
+ urlsMatching,
+ urlsNonMatching,
+ }) {
+ browser.test.assertDeepEq(
+ { isSupported: true },
+ await dnr.isRegexSupported({
+ regex: regexFilter,
+ isCaseSensitive: isUrlFilterCaseSensitive,
+ }),
+ `isRegexSupported should detect support for: ${regexFilter}`
+ );
+ await _testRegexFilterOrRedirect({
+ description,
+ regexFilter,
+ isUrlFilterCaseSensitive,
+ expectedRedirectUrl: "http://dest/",
+ regexSubstitution: "http://dest/",
+ urlsMatching,
+ urlsNonMatching,
+ });
+ }
+
+ async function testValidRegexSubstitution({
+ description,
+ regexFilter,
+ regexSubstitution,
+ inputUrl,
+ expectedRedirectUrl,
+ }) {
+ browser.test.assertDeepEq(
+ { isSupported: true },
+ await dnr.isRegexSupported({
+ regex: regexFilter,
+ // requireCapturing option not strictly needed, but included to verify
+ // that the method can take the option without issues.
+ requireCapturing: true,
+ }),
+ `isRegexSupported should accept regexFilter: ${regexFilter}`
+ );
+
+ await _testRegexFilterOrRedirect({
+ description,
+ regexFilter,
+ regexSubstitution,
+ urlsMatching: [inputUrl],
+ urlsNonMatching: [],
+ expectedRedirectUrl,
+ });
+ }
+
+ async function testInvalidRegexFilter(regexFilter, expectedError, msg) {
+ browser.test.assertDeepEq(
+ { isSupported: false, reason: "syntaxError" },
+ await dnr.isRegexSupported({ regex: regexFilter }),
+ `isRegexSupported should detect unsupported regex: ${regexFilter}`
+ );
+ await browser.test.assertRejects(
+ dnr.updateSessionRules({
+ addRules: [
+ { id: 123, condition: { regexFilter }, action: { type: "block" } },
+ ],
+ }),
+ expectedError,
+ `Should reject invalid regexFilter (${regexFilter}) - ${msg}`
+ );
+ }
+
+ async function testInvalidRegexSubstitution(
+ regexSubstitution,
+ expectedError,
+ msg
+ ) {
+ await browser.test.assertRejects(
+ _testRegexFilterOrRedirect({
+ description: `testInvalidRegexSubstitution: "${regexSubstitution}"`,
+ regexFilter: ".",
+ regexSubstitution,
+ urlsMatching: [],
+ urlsNonMatching: [],
+ }),
+ expectedError,
+ msg
+ );
+ }
+
+ async function testRejectedRedirectAtRuntime({ regexSubstitution, url }) {
+ // Some regexSubstitution rules pass validation but the generated redirect
+ // URL is rejected at runtime. That is validated here.
+ await _testRegexFilterOrRedirect({
+ description: `testRejectedRedirectAtRuntime for URL: ${url}`,
+ regexFilter: "http://from/.*",
+ regexSubstitution,
+ // When regexSubstitution is invalid, it should not be redirected:
+ expectedRedirectUrl: url,
+ urlsMatching: [url],
+ urlsNonMatching: [],
+ });
+ }
+
+ Object.assign(dnrTestUtils, {
+ testValidRegexFilter,
+ testValidRegexSubstitution,
+ testInvalidRegexFilter,
+ testInvalidRegexSubstitution,
+ testRejectedRedirectAtRuntime,
+ });
+ return dnrTestUtils;
+}
+
+async function runAsDNRExtension({ background, manifest }) {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${background})((${makeDnrTestUtils})())`,
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest"],
+ // host_permissions are needed for the redirect action.
+ host_permissions: ["<all_urls>"],
+ granted_host_permissions: true,
+ ...manifest,
+ },
+ temporarilyInstalled: true, // <-- for granted_host_permissions
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+// The least common denominator across Chrome, Safari and Firefox is Safari, at
+// the time of writing, the supported syntax in Safari's regexFilter is
+// documented at https://webkit.org/blog/3476/content-blockers-first-look/,
+// section "The Regular expression format":
+//
+// - Matching any character with “.”.
+// - Matching ranges with the range syntax [a-b].
+// - Quantifying expressions with “?”, “+” and “*”.
+// - Groups with parenthesis.
+// - ... beginning of line (“^”) and end of line (“$”) marker ...
+//
+// The above syntax is very limited, as expressed at
+// https://github.com/w3c/webextensions/issues/344
+//
+// The tests continue in regexFilter_more_than_basic.
+add_task(async function regexFilter_basic() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testValidRegexFilter } = dnrTestUtils;
+
+ await testValidRegexFilter({
+ description: "URL as regexFilter is sometimes a valid regexp",
+ regexFilter: "http://example.com/",
+ urlsMatching: [
+ "http://example.com/",
+ // dot is wildcard.
+ "http://example-com/",
+ // Without ^ anchor, matches substring elsewhere.
+ "http://from/http://example.com/",
+ ],
+ urlsNonMatching: [
+ "http://dest/http://example.com-no-slash-after-.com",
+ // Does not match reference fragment.
+ "http://dest/#http://example.com/",
+ ],
+ });
+
+ await testValidRegexFilter({
+ description: "\\. is literal dot",
+ regexFilter: "http://example\\.com/",
+ urlsMatching: ["http://example.com/"],
+ urlsNonMatching: ["http://example-com/"],
+ });
+
+ await testValidRegexFilter({
+ description: "[a-b] range is supported",
+ regexFilter: "http://from/[a-b]",
+ urlsMatching: ["http://from/a", "http://from/b"],
+ urlsNonMatching: ["http://from/c", "http://from/"],
+ });
+
+ await testValidRegexFilter({
+ description: "groups with parenthesis are supported",
+ regexFilter: "http://from/(a)",
+ urlsMatching: ["http://from/a", "http://from/aa"],
+ urlsNonMatching: ["http://from/b", "http://from/ba"],
+ });
+
+ await testValidRegexFilter({
+ description: "+, * and ? are quantifiers",
+ regexFilter: "a+b*c?d",
+ urlsMatching: [
+ "http://from/ad",
+ "http://from/abcd",
+ "http://from/aaabbcd",
+ ],
+ urlsNonMatching: [
+ "http://from/bcd", // "a+" requires "a" to be specified.
+ "http://from/abccd", // "c?" matches only one c, but got two.
+ ],
+ });
+
+ await testValidRegexFilter({
+ description: ".* matches anything",
+ regexFilter: "a.*b",
+ urlsMatching: ["http://from/ab/", "http://from/aANYTHINGb"],
+ urlsNonMatching: ["http://from/a"],
+ });
+
+ await testValidRegexFilter({
+ description: "^ is start-of-string anchor",
+ regexFilter: "^http://from/",
+ urlsMatching: ["http://from/", "http://from/path"],
+ urlsNonMatching: ["http://dest/^http://from/"],
+ });
+
+ await testValidRegexFilter({
+ description: "$ is end-of-string anchor",
+ regexFilter: "http://from/$",
+ urlsMatching: ["http://from/", "http://dest/http://from/"],
+ urlsNonMatching: ["http://from/path", "http://from/$"],
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// regexFilter_basic lists the bare minimum, this tests more useful features.
+add_task(async function regexFilter_more_than_basic() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testValidRegexFilter } = dnrTestUtils;
+
+ // Use cases listed at
+ // https://github.com/w3c/webextensions/issues/344#issuecomment-1430358116
+
+ await testValidRegexFilter({
+ description: "{n,m} quantifier",
+ regexFilter: "http://from/a{2,3}b",
+ urlsMatching: ["http://from/aab", "http://from/aaab"],
+ urlsNonMatching: ["http://from/ab", "http://from/aaaab"],
+ });
+
+ await testValidRegexFilter({
+ description: "{n,} quantifier",
+ regexFilter: "http://from/a{2,}$",
+ urlsMatching: ["http://from/aa", "http://from/aaa", "http://from/aaaa"],
+ urlsNonMatching: ["http://from/a"],
+ });
+
+ await testValidRegexFilter({
+ description: "| disjunction and within groups",
+ regexFilter: "from/a|from/b$|c$",
+ urlsMatching: ["http://from/a", "http://from/b", "http://from/c"],
+ urlsNonMatching: ["http://from/b$|c$"],
+ });
+
+ await testValidRegexFilter({
+ description: "(?!) negative look-ahead",
+ regexFilter: "http://from/a(?!notme|$)",
+ urlsMatching: ["http://from/aOK"],
+ urlsNonMatching: ["http://from/anotme", "http://from/a"],
+ });
+
+ // Features based on
+ // https://github.com/w3c/webextensions/issues/344#issuecomment-1430127543
+ await testValidRegexFilter({
+ description: "Negated character class",
+ regexFilter: "http://from/[^a-z]",
+ urlsMatching: ["http://from/1"],
+ urlsNonMatching: ["http://from/a", "http://from/y", "http://from/"],
+ });
+
+ await testValidRegexFilter({
+ description: "Word character class (\\w)",
+ regexFilter: "http://from/\\w",
+ urlsMatching: ["http://from/1", "http://from/a", "http://from/_"],
+ urlsNonMatching: ["http://from/-", "http://from/%20"],
+ });
+
+ // Rule that leads to "memoryLimitExceeded" in Chrome:
+ // https://github.com/w3c/webextensions/issues/344#issuecomment-1424527627
+ await testValidRegexFilter({
+ description: "regexFilter that triggers memoryLimitExceeded in Chrome",
+ regexFilter: "(https?://)104\\.154\\..{100,}",
+ urlsMatching: ["http://from/http://104.154.0.0/" + "x".repeat(100)],
+ urlsNonMatching: ["http://from/http://104.154.0.0/too-short"],
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// Adds more coverage in addition to what was tested by validate_regexFilter in
+// test_ext_dnr_session_rules.js.
+add_task(async function regexFilter_invalid() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testInvalidRegexFilter } = dnrTestUtils;
+
+ await testInvalidRegexFilter(
+ "(",
+ "regexFilter is not a valid regular expression",
+ "( opens a group and should be closed"
+ );
+
+ await testInvalidRegexFilter(
+ "straß.d",
+ "regexFilter should not contain non-ASCII characters",
+ "regexFilter matches the canonical URL which does not contain non-ASCII"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function regexFilter_isUrlFilterCaseSensitive() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testValidRegexFilter } = dnrTestUtils;
+
+ await testValidRegexFilter({
+ description: "isUrlFilterCaseSensitive omitted (= false by default)",
+ // isUrlFilterCaseSensitive = false by default.
+ regexFilter: "from/Pa",
+ urlsMatching: ["http://from/Pa", "http://from/pa", "http://from/PA"],
+ urlsNonMatching: [],
+ });
+
+ await testValidRegexFilter({
+ description: "isUrlFilterCaseSensitive: false",
+ isUrlFilterCaseSensitive: false,
+ regexFilter: "from/Pa",
+ urlsMatching: ["http://from/Pa", "http://from/pa", "http://from/PA"],
+ urlsNonMatching: [],
+ });
+
+ await testValidRegexFilter({
+ description: "isUrlFilterCaseSensitive: true",
+ isUrlFilterCaseSensitive: true,
+ regexFilter: "from/Pa",
+ urlsMatching: ["http://from/Pa"],
+ urlsNonMatching: ["http://from/pa", "http://from/PA"],
+ });
+
+ await testValidRegexFilter({
+ description: "Case-sensitive uppercase regexFilter cannot match HOST",
+ isUrlFilterCaseSensitive: true,
+ regexFilter: "FROM",
+ urlsMatching: [],
+ urlsNonMatching: ["http://FROM/canonical_host_is_lowercase"],
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function regexSubstitution_invalid() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await runAsDNRExtension({
+ manifest: { browser_specific_settings: { gecko: { id: "@dnr" } } },
+ background: async dnrTestUtils => {
+ const { testRejectedRedirectAtRuntime, testInvalidRegexSubstitution } =
+ dnrTestUtils;
+
+ await testInvalidRegexSubstitution(
+ "http://dest/\\x20",
+ "redirect.regexSubstitution only allows digit or \\ after \\.",
+ "\\x should not be allowed in regexSubstitution"
+ );
+
+ await testInvalidRegexSubstitution(
+ "http://dest/?\\",
+ "redirect.regexSubstitution only allows digit or \\ after \\.",
+ "\\<end> should not be allowed in regexSubstitution"
+ );
+
+ await testRejectedRedirectAtRuntime({
+ regexSubstitution: "not-URL",
+ url: "http://from/should_not_be_directed_invalid_url",
+ });
+
+ await testRejectedRedirectAtRuntime({
+ regexSubstitution: "javascript://-URL",
+ url: "http://from/should_not_be_directed_javascript_url",
+ });
+
+ // May be allowed once bug 1622986 is fixed.
+ await testRejectedRedirectAtRuntime({
+ regexSubstitution: "data:,redirect-from-dnr",
+ url: "http://from/should_not_be_directed_disallowed_url",
+ });
+
+ await testRejectedRedirectAtRuntime({
+ regexSubstitution: "resource://gre/",
+ url: "http://from/should_not_be_directed_resource_url",
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ {
+ message: /Extension @dnr tried to redirect to an invalid URL: not-URL/,
+ },
+ {
+ message: /Extension @dnr may not redirect to: javascript:\/\/-URL/,
+ },
+ {
+ message: /Extension @dnr may not redirect to: data:,redirect-from-dnr/,
+ },
+ {
+ message: /Extension @dnr may not redirect to: resource:\/\/gre\//,
+ },
+ ],
+ });
+});
+
+add_task(async function regexSubstitution_valid() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testValidRegexSubstitution } = dnrTestUtils;
+
+ await testValidRegexSubstitution({
+ description: "All captured groups can be accessed by \\1 - \\9",
+ regexFilter: "from/(.)(.)(.)(.)(.)(.)(.)(.)(.)(.)(.)",
+ regexSubstitution: "http://dest/\\9\\8\\7\\6\\5\\4\\3\\2\\1",
+ inputUrl: "http://from/abcdef?gh-ignoredsuffix",
+ // ^ captured groups: 123456789
+ expectedRedirectUrl: "http://dest/hg?fedcba",
+ });
+
+ await testValidRegexSubstitution({
+ description: "\\0 captures the full match",
+ regexFilter: "from/$",
+ regexSubstitution: "http://dest/\\0/end",
+ inputUrl: "http://from/",
+ expectedRedirectUrl: "http://dest/from//end",
+ });
+
+ await testValidRegexSubstitution({
+ description: "\\10 means: captured group 1 + literal 0",
+ regexFilter: "/(captured)$",
+ regexSubstitution: "http://dest/\\10",
+ inputUrl: "http://from/captured",
+ expectedRedirectUrl: "http://dest/captured0",
+ });
+
+ await testValidRegexSubstitution({
+ description: "\\\\ is an escaped backslash",
+ regexFilter: "/(XXX)",
+ regexSubstitution: "http://dest/?\\1\\\\1\\\\\\\\1\\1",
+ inputUrl: "http://from/XXX",
+ expectedRedirectUrl: "http://dest/?XXX\\1\\\\1XXX",
+ });
+
+ await testValidRegexSubstitution({
+ description: "Captured groups can be repeated",
+ regexFilter: "/(captured)$",
+ regexSubstitution: "http://dest/\\1+\\1",
+ inputUrl: "http://from/captured",
+ expectedRedirectUrl: "http://dest/captured+captured",
+ });
+
+ await testValidRegexSubstitution({
+ description: "Non-matching optional group is an empty string",
+ regexFilter: "(doesnotmatch)?suffix",
+ regexSubstitution: "http://dest/[\\1]=group1_is_optional",
+ inputUrl: "http://from/suffix",
+ expectedRedirectUrl: "http://dest/[]=group1_is_optional",
+ });
+
+ await testValidRegexSubstitution({
+ description: "Non-existing capturing group is an empty string",
+ regexFilter: "(captured)",
+ regexSubstitution: "http://dest/[\\2]=missing_group_2",
+ inputUrl: "http://from/captured",
+ expectedRedirectUrl: "http://dest/[]=missing_group_2",
+ });
+
+ await testValidRegexSubstitution({
+ description: "Non-capturing group is not captured",
+ regexFilter: "(?:non-)(captured)",
+ regexSubstitution: "http://dest/[\\1]=only_captured_group",
+ inputUrl: "http://from/non-captured",
+ expectedRedirectUrl: "http://dest/[captured]=only_captured_group",
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function regexSubstitution_redirect_chain() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testValidRegexSubstitution } = dnrTestUtils;
+
+ await testValidRegexSubstitution({
+ description: "regexFilter matches intermediate redirect URLs",
+ regexFilter: "^(http://from/)(a|b|c)(.+)",
+ regexSubstitution: "\\1\\3",
+ inputUrl: "http://from/abcdef",
+ // After redirecting three times, we end up here:
+ expectedRedirectUrl: "http://from/def",
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js
new file mode 100644
index 0000000000..443f69c2d1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js
@@ -0,0 +1,549 @@
+"use strict";
+
+// This file verifies the quota on regexFilter rules for all ruleset.
+//
+// The generic rule limits (not specific to regexFilter) are covered elsewhere:
+// - session_rules_total_rule_limit in test_ext_dnr_session_rules.js
+// - test_dynamic_rules_count_limits in test_ext_dnr_dynamic_rules.js
+// (also checks that the quota of session and dynamic rules are separate.)
+// - test_getAvailableStaticRulesCountAndLimits and test_static_rulesets_limits
+// in test_ext_dnr_static_rules.js.
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+add_setup(async () => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.feedback", true);
+
+ // We want to install add-ons through the add-on manager in order to be able
+ // to disable / re-enable the add-on.
+ await ExtensionTestUtils.startAddonManager();
+});
+
+const _origDescs = {};
+function restoreDefaultDnrLimit(key) {
+ info(`Restoring original value of ExtensionDNRLimits.${key}`);
+ Object.defineProperty(ExtensionDNRLimits, key, _origDescs[key]);
+}
+function overrideDefaultDnrLimit(key, value) {
+ // Until DNR limits can be customized through prefs (bug 1803370), we need to
+ // overwrite the internals here in the parent process. That is sufficient to
+ // control the limits. Notably, this does NOT affect the values of the
+ // constants exposed through the declarativeNetRequest keyspace, because
+ // their values are directly read from the extension (child) process.
+ if (!_origDescs[key]) {
+ _origDescs[key] = Object.getOwnPropertyDescriptor(ExtensionDNRLimits, key);
+ registerCleanupFunction(() => restoreDefaultDnrLimit(key));
+ }
+ Assert.ok(
+ typeof value === "number" && Number.isInteger(value),
+ `Setting ExtensionDNRLimits.${key} = ${value} (was: ${ExtensionDNRLimits[key]})`
+ );
+ Object.defineProperty(ExtensionDNRLimits, key, {
+ configurable: true,
+ writable: true,
+ enumerable: true,
+ value,
+ });
+}
+
+// Create an extension composed of the given test cases, and start or reload
+// the extension before each test case.
+//
+// testCases is an array of:
+// - name - unique name describing purpose of test
+// - setup - optional function run before (re-)enabling the extension.
+// - backgroundFn - logic to run in the extension's background.
+// - checkConsoleMessages - function to run to verify the console messages
+// collected between extension (re)startup and the execution of backgroundFn.
+//
+// extensionDataTemplate should be a value for ExtensionTestUtils.loadExtension,
+// without the background key.
+async function startOrReloadExtensionForEach(testCases, extensionDataTemplate) {
+ for (let testCase of testCases) {
+ // Verify that the keys are supported, so that the test does not pass
+ // trivially because of a typo or something.
+ let okKeys = ["name", "setup", "backgroundFn", "checkConsoleMessages"];
+ let keys = Object.keys(testCase).filter(k => !okKeys.includes(k));
+ if (keys.length) {
+ throw new Error(`Unexpected key in testCase ${testCase.name}: ${keys}`);
+ }
+ }
+ if (extensionDataTemplate.background) {
+ // background is generated here, so the template should not specify it.
+ throw new Error("extensionDataTemplate.background should not be set");
+ }
+ function background(testCases) {
+ browser.test.onMessage.addListener(async testName => {
+ try {
+ browser.test.log(`Starting backgroundFn for ${testName}`);
+ await testCases.find(({ name }) => name === testName).backgroundFn();
+ } catch (e) {
+ browser.test.fail(`Unexpected error for ${testName}: ${e}`);
+ }
+ browser.test.log(`Completed backgroundFn for ${testName}`);
+ browser.test.sendMessage(`${testName}:done`);
+ });
+ browser.test.sendMessage("background_started");
+ }
+
+ const serializedTestCases = testCases.map(
+ ({ name, backgroundFn }) => `{name:"${name}",backgroundFn:${backgroundFn}}`
+ );
+ let extension = ExtensionTestUtils.loadExtension({
+ ...extensionDataTemplate,
+ background: `(${background})([${serializedTestCases.join(",")}])`,
+ });
+
+ for (let [i, { name, setup, checkConsoleMessages }] of testCases.entries()) {
+ info(`Running test case: ${name}`);
+ await setup?.();
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ if (i === 0) {
+ await extension.startup();
+ } else {
+ await extension.addon.enable();
+ }
+ await extension.awaitMessage("background_started");
+
+ // DNR rule loading errors should be emitted at startup. But since the
+ // rule loading is async and not blocking background startup, we need to
+ // roundtrip through the DNR API before we can verify the error message.
+ extension.sendMessage(name);
+ await extension.awaitMessage(`${name}:done`);
+ });
+
+ checkConsoleMessages(name, messages);
+
+ if (i === testCases.length - 1) {
+ await extension.unload();
+ } else {
+ await extension.addon.disable();
+ }
+ info(`Completed test case: ${name}`);
+ }
+}
+
+// Create the extensionDataTemplate value (without "background" key!) for use
+// with ExtensionTestUtils.loadExtension, through startOrReloadExtensionForEach.
+function makeExtensionDataTemplate({ manifest, files }) {
+ return {
+ // Note: no "background" key because startOrReloadExtensionForEach adds it.
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ browser_specific_settings: { gecko: { id: "dnr@ext" } },
+ ...manifest,
+ },
+ files,
+ };
+}
+
+function genStaticRules(count) {
+ let rules = [];
+ for (let i = 1; i <= count; ++i) {
+ rules.push({
+ id: i,
+ condition: { regexFilter: `prefix_${i}_suffix` },
+ action: { type: "block" },
+ });
+ }
+ return JSON.stringify(rules);
+}
+
+add_task(async function session_and_dynamic_regexFilter_limit() {
+ let extensionDataTemplate = makeExtensionDataTemplate({});
+
+ // Note: Every testPart* function will be serialized and be part of the test
+ // extension's background script.
+
+ async function testPart1_session_and_dynamic_quota() {
+ let rules = [];
+ const { MAX_NUMBER_OF_REGEX_RULES } = browser.declarativeNetRequest;
+ for (let i = 1; i <= MAX_NUMBER_OF_REGEX_RULES; ++i) {
+ rules.push({
+ id: i,
+ condition: { regexFilter: `prefix_${i}_suffix` },
+ action: { type: "block" },
+ });
+ }
+ const lastRuleId = rules[rules.length - 1].id;
+
+ browser.test.log(`Adding ${rules.length} regex rules (dynamic)`);
+ await browser.declarativeNetRequest.updateDynamicRules({
+ addRules: rules,
+ });
+
+ browser.test.assertDeepEq(
+ { matchedRules: [{ ruleId: lastRuleId, rulesetId: "_dynamic" }] },
+ await browser.declarativeNetRequest.testMatchOutcome({
+ url: `http://example.com/prefix_${lastRuleId}_suffix`,
+ type: "other",
+ }),
+ "Expected last regexFilter to match the request"
+ );
+
+ // Dynamic and session rules should have a separate quota for regexFilter.
+ browser.test.log(`Adding ${rules.length} regex rules (session)`);
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: rules,
+ });
+
+ browser.test.assertDeepEq(
+ { matchedRules: [{ ruleId: lastRuleId, rulesetId: "_session" }] },
+ await browser.declarativeNetRequest.testMatchOutcome({
+ url: `http://example.com/prefix_${lastRuleId}_suffix`,
+ type: "other",
+ }),
+ "Expected registered regexFilter to match"
+ );
+
+ // Now we should not be able to add another one.
+ const newRule = {
+ id: lastRuleId + 1,
+ condition: { regexFilter: "." },
+ action: { type: "block" },
+ };
+ await browser.test.assertRejects(
+ browser.declarativeNetRequest.updateSessionRules({ addRules: [newRule] }),
+ `Number of regexFilter rules in ruleset "_session" exceeds MAX_NUMBER_OF_REGEX_RULES.`,
+ "Should not allow regexFilter over quota for session ruleset"
+ );
+ await browser.test.assertRejects(
+ browser.declarativeNetRequest.updateDynamicRules({ addRules: [newRule] }),
+ `Number of regexFilter rules in ruleset "_dynamic" exceeds MAX_NUMBER_OF_REGEX_RULES.`,
+ "Should not allow regexFilter over quota for dynamic ruleset"
+ );
+ }
+
+ async function testPart2_after_reload() {
+ browser.test.assertEq(
+ 0,
+ (await browser.declarativeNetRequest.getSessionRules()).length,
+ "Session rules gone after restart"
+ );
+ let rules = await browser.declarativeNetRequest.getDynamicRules();
+ browser.test.assertEq(
+ browser.declarativeNetRequest.MAX_NUMBER_OF_REGEX_RULES,
+ rules.length,
+ "Dynamic regexFilter rules still there after restart"
+ );
+
+ // This confirms that the quota for session rules is not somehow persisted
+ // somewhere else.
+ browser.test.log(`Verifying that we can add ${rules.length} rules again.`);
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: rules,
+ });
+ }
+
+ async function testPart3_too_many_regexFilters_stored_after_lowering_quota() {
+ browser.test.assertEq(
+ 0,
+ (await browser.declarativeNetRequest.getDynamicRules()).length,
+ "Ignore all stored dynamic rules when regexFilter quota is exceeded"
+ );
+ }
+
+ async function testPart4_reload_after_quota_back() {
+ // Implementation detail: while the in-memory representation of the
+ // dynamic rules has been wiped at the previous extension load, the disk
+ // representation did not change because we only read the dynamic rules
+ // without anything else triggering a save request.
+ //
+ // Therefore, when the limit was somehow restored, the on-disk data is
+ // now considered valid again.
+ browser.test.assertEq(
+ browser.declarativeNetRequest.MAX_NUMBER_OF_REGEX_RULES,
+ (await browser.declarativeNetRequest.getDynamicRules()).length,
+ "On-disk dynamic rules accepted when regexFilter quota is not exceeded"
+ );
+ }
+
+ // Expected warning in console when there are too many regexFilter rules in
+ // the dynamic ruleset data on disk.
+ const errorMsg = `Ignoring dynamic ruleset in extension "dnr@ext" because: Number of regexFilter rules in ruleset "_dynamic" exceeds MAX_NUMBER_OF_REGEX_RULES.`;
+ function expectError(testName, messages) {
+ Assert.equal(
+ messages.filter(m => m.message.includes(errorMsg)).length,
+ 1,
+ `${testName} should trigger the following error in the log: ${errorMsg}`
+ );
+ }
+ function noErrors(testName, messages) {
+ Assert.equal(
+ messages.filter(m => m.message.includes(errorMsg)).length,
+ 0,
+ `${testName} should not trigger any logged errors`
+ );
+ }
+
+ const testCases = [
+ {
+ name: "testPart1_session_and_dynamic_quota",
+ backgroundFn: testPart1_session_and_dynamic_quota,
+ checkConsoleMessages: noErrors,
+ },
+ {
+ name: "testPart2_after_reload",
+ backgroundFn: testPart2_after_reload,
+ checkConsoleMessages: noErrors,
+ },
+ {
+ name: "testPart3_too_many_regexFilters_stored_after_lowering_quota",
+ setup() {
+ // Artificially decrease the max number of allowed regexFilter rules,
+ // so that whatever that was stored on disk is no longer within quota.
+ overrideDefaultDnrLimit("MAX_NUMBER_OF_REGEX_RULES", 1);
+ },
+ backgroundFn: testPart3_too_many_regexFilters_stored_after_lowering_quota,
+ checkConsoleMessages: expectError,
+ },
+ {
+ name: "testPart4_reload_after_quota_back",
+ setup() {
+ // Restore the original quota after it was lowered in testPart3.
+ restoreDefaultDnrLimit("MAX_NUMBER_OF_REGEX_RULES");
+ },
+ backgroundFn: testPart4_reload_after_quota_back,
+ checkConsoleMessages: noErrors,
+ },
+ ];
+
+ await startOrReloadExtensionForEach(testCases, extensionDataTemplate);
+});
+
+add_task(async function static_regexFilter_limit() {
+ const { MAX_NUMBER_OF_REGEX_RULES } = ExtensionDNRLimits;
+
+ let extensionDataTemplate = makeExtensionDataTemplate({
+ manifest: {
+ declarative_net_request: {
+ rule_resources: [
+ // limit_plus_1 is over quota, but the other rules should be loaded
+ // if possible.
+ { id: "limit_plus_1", path: "limit_plus_1.json", enabled: true },
+ { id: "just_one", path: "just_one.json", enabled: true },
+ { id: "just_two", path: "just_two.json", enabled: true },
+ { id: "limit_minus_2", path: "limit_minus_2.json", enabled: true },
+ { id: "limit_minus_1", path: "limit_minus_1.json", enabled: false },
+ ],
+ },
+ },
+ files: {
+ "limit_plus_1.json": genStaticRules(MAX_NUMBER_OF_REGEX_RULES + 1),
+ "just_one.json": genStaticRules(1),
+ "just_two.json": genStaticRules(2),
+ "limit_minus_2.json": genStaticRules(MAX_NUMBER_OF_REGEX_RULES - 2),
+ "limit_minus_1.json": genStaticRules(MAX_NUMBER_OF_REGEX_RULES - 1),
+ },
+ });
+
+ // Note: Every testPart* function will be serialized and be part of the test
+ // extension's background script.
+
+ async function testPart1_start_over_static_quota() {
+ browser.test.assertDeepEq(
+ ["just_one", "just_two"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Should only load rules that fit in the quota for static rules"
+ );
+ }
+
+ async function testPart2_after_reload() {
+ // Should still be the same.
+ browser.test.assertDeepEq(
+ ["just_one", "just_two"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Should only load rules that fit in the quota for static rules (again)"
+ );
+
+ await browser.declarativeNetRequest.updateEnabledRulesets({
+ disableRulesetIds: ["just_one"],
+ });
+
+ browser.test.assertDeepEq(
+ ["just_two"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "After disabling 'just_one', there should only be one enabled ruleset"
+ );
+ }
+
+ async function testPart3_after_updateEnabledRulesets_within_limit() {
+ browser.test.assertDeepEq(
+ ["just_two"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "limit_minus_2 should still be disabled because of a previous updateEnabledRulesets call"
+ );
+
+ // This should succeed, as there is now enough space.
+ await browser.declarativeNetRequest.updateEnabledRulesets({
+ enableRulesetIds: ["limit_minus_2"],
+ });
+ browser.test.assertDeepEq(
+ ["just_two", "limit_minus_2"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "limit_minus_2 should be enabled by updateEnabledRulesets"
+ );
+
+ await browser.test.assertRejects(
+ browser.declarativeNetRequest.updateEnabledRulesets({
+ enableRulesetIds: ["just_one"],
+ }),
+ `Number of regexFilter rules across all enabled static rulesets exceeds MAX_NUMBER_OF_REGEX_RULES if ruleset "just_one" were to be enabled.`,
+ "Should not be able to enable just_one because limit was reached"
+ );
+ }
+
+ async function testPart4_toggling_rulesets_at_quota() {
+ browser.test.assertDeepEq(
+ ["just_two", "limit_minus_2"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Should have the two rulesets occupying all quota from the previous run"
+ );
+
+ await browser.declarativeNetRequest.updateEnabledRulesets({
+ disableRulesetIds: ["just_two"],
+ enableRulesetIds: ["just_one"],
+ });
+ browser.test.assertDeepEq(
+ ["just_one", "limit_minus_2"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Should be able to replace a ruleset as long as the result is within quota"
+ );
+
+ // Try to enable just_one + just_two (+existing limit_minus_2).
+ await browser.test.assertRejects(
+ browser.declarativeNetRequest.updateEnabledRulesets({
+ disableRulesetIds: ["just_one"],
+ enableRulesetIds: ["just_one", "just_two"],
+ }),
+ `Number of regexFilter rules across all enabled static rulesets exceeds MAX_NUMBER_OF_REGEX_RULES if ruleset "just_two" were to be enabled.`,
+ "Should reject updateEnabledRulesets that would exceed the quota by 1"
+ );
+ browser.test.assertDeepEq(
+ ["just_one", "limit_minus_2"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Rulesets should not have changed due to rejection"
+ );
+ }
+
+ async function testPart5_after_doubling_quota() {
+ browser.test.assertDeepEq(
+ ["just_one", "limit_minus_2"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Initial ruleset not changed after bumping the quota"
+ );
+ // Explicitly disable before re-enabling them all to see whether the order
+ // of passed-in rulesets has any impact on the evaluation order at startup.
+ await browser.declarativeNetRequest.updateEnabledRulesets({
+ disableRulesetIds: ["limit_minus_2", "just_one"],
+ });
+ await browser.declarativeNetRequest.updateEnabledRulesets({
+ enableRulesetIds: [
+ "limit_minus_2",
+ "just_two",
+ "just_one",
+ "limit_minus_1",
+ ],
+ });
+ browser.test.assertDeepEq(
+ ["just_one", "just_two", "limit_minus_2", "limit_minus_1"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "All rulesets within new quota - ruleset order should match manifest order"
+ );
+ }
+
+ async function testPart6_after_restoring_original_quota_half() {
+ browser.test.assertDeepEq(
+ ["just_one", "just_two"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Should have trimmed excess rules in the manifest order"
+ );
+ }
+
+ const errorMsgPattern =
+ /Ignoring static ruleset "([^"]+)" in extension "dnr@ext" because: Number of regexFilter rules across all enabled static rulesets exceeds MAX_NUMBER_OF_REGEX_RULES if ruleset "\1" were to be enabled./;
+ function checkFailedRulesets(testName, messages, rulesetIds) {
+ let actualRulesetIds = messages
+ .map(m => errorMsgPattern.exec(m.message)?.[1])
+ .filter(Boolean);
+ Assert.deepEqual(
+ rulesetIds,
+ actualRulesetIds,
+ `${testName} should only trigger errors for rejected rulesets at start`
+ );
+ }
+
+ const testCases = [
+ {
+ name: "testPart1_start_over_static_quota",
+ backgroundFn: testPart1_start_over_static_quota,
+ checkConsoleMessages: (n, m) =>
+ checkFailedRulesets(n, m, ["limit_plus_1", "limit_minus_2"]),
+ },
+ {
+ name: "testPart2_after_reload",
+ backgroundFn: testPart2_after_reload,
+ // The extension has not called updateEnabledRulesets, so the "enabled"
+ // state of limit_minus_2 from manifest.json is still the extension's
+ // desired state for the ruleset. When the browser thus tries to enable
+ // the ruleset, it should encounter the same error as before.
+ //
+ // An alternative would be for the latest understanding of "enabled" to
+ // be persisted to disk and used when we load the persisted ruleset state.
+ // But if we do that, then we would not be able to distinguish "disabled
+ // because of a browser limit" from "disabled by extension". And if we
+ // cannot do that, then we would not be able to enable rulesets from
+ // already-installed extensions if we were to bump the limits in a browser
+ // update.
+ //
+ // Note: even if caching is implemented (bug 1803365), the observed
+ // behavior should happen, because the cache is cleared when we disable
+ // the extension.
+ checkConsoleMessages: (n, m) =>
+ checkFailedRulesets(n, m, ["limit_plus_1", "limit_minus_2"]),
+ },
+ {
+ name: "testPart3_after_updateEnabledRulesets_within_limit",
+ backgroundFn: testPart3_after_updateEnabledRulesets_within_limit,
+ checkConsoleMessages: (n, m) => checkFailedRulesets(n, m, []),
+ },
+ {
+ name: "testPart4_toggling_rulesets_at_quota",
+ backgroundFn: testPart4_toggling_rulesets_at_quota,
+ checkConsoleMessages: (n, m) => checkFailedRulesets(n, m, []),
+ },
+ {
+ name: "testPart5_after_doubling_quota",
+ setup() {
+ overrideDefaultDnrLimit(
+ "MAX_NUMBER_OF_REGEX_RULES",
+ 2 * MAX_NUMBER_OF_REGEX_RULES
+ );
+ },
+ backgroundFn: testPart5_after_doubling_quota,
+ checkConsoleMessages: (n, m) => checkFailedRulesets(n, m, []),
+ },
+ {
+ name: "testPart6_after_restoring_original_quota_half",
+ setup() {
+ // Restore the original quota after it was raised in testPart5.
+ restoreDefaultDnrLimit("MAX_NUMBER_OF_REGEX_RULES");
+ },
+ backgroundFn: testPart6_after_restoring_original_quota_half,
+ checkConsoleMessages: (n, m) =>
+ checkFailedRulesets(n, m, ["limit_minus_2", "limit_minus_1"]),
+ },
+ ];
+
+ await startOrReloadExtensionForEach(testCases, extensionDataTemplate);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_session_rules.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_session_rules.js
new file mode 100644
index 0000000000..5f0b0d72a2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_session_rules.js
@@ -0,0 +1,1111 @@
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs",
+});
+
+add_setup(() => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+});
+
+// This function is serialized and called in the context of the test extension's
+// background page. dnrTestUtils is passed to the background function.
+function makeDnrTestUtils() {
+ const dnrTestUtils = {};
+ const dnr = browser.declarativeNetRequest;
+ dnrTestUtils.makeRuleInput = id => {
+ return {
+ id,
+ condition: {},
+ action: { type: "block" },
+ };
+ };
+ dnrTestUtils.makeRuleOutput = id => {
+ return {
+ id,
+ condition: {
+ urlFilter: null,
+ regexFilter: null,
+ isUrlFilterCaseSensitive: null,
+ initiatorDomains: null,
+ excludedInitiatorDomains: null,
+ requestDomains: null,
+ excludedRequestDomains: null,
+ resourceTypes: null,
+ excludedResourceTypes: null,
+ requestMethods: null,
+ excludedRequestMethods: null,
+ domainType: null,
+ tabIds: null,
+ excludedTabIds: null,
+ },
+ action: {
+ type: "block",
+ redirect: null,
+ requestHeaders: null,
+ responseHeaders: null,
+ },
+ priority: 1,
+ };
+ };
+
+ function serializeForLog(rule) {
+ // JSON-stringify, but drop null values (replacing them with undefined
+ // causes JSON.stringify to drop them), so that optional keys with the null
+ // values are hidden.
+ let str = JSON.stringify(rule, rep => rep ?? undefined);
+ // VERY_LONG_STRING consists of many 'X'. Shorten to avoid logspam.
+ str = str.replace(/x{10,}/g, xxx => `x{${xxx.length}}`);
+ return str;
+ }
+
+ async function testInvalidRule(rule, expectedError, isSchemaError) {
+ if (isSchemaError) {
+ // Schema validation error = thrown error instead of a rejection.
+ browser.test.assertThrows(
+ () => dnr.updateSessionRules({ addRules: [rule] }),
+ expectedError,
+ `Rule should be invalid (schema-validated): ${serializeForLog(rule)}`
+ );
+ } else {
+ await browser.test.assertRejects(
+ dnr.updateSessionRules({ addRules: [rule] }),
+ expectedError,
+ `Rule should be invalid: ${serializeForLog(rule)}`
+ );
+ }
+ }
+ async function testInvalidCondition(condition, expectedError, isSchemaError) {
+ await testInvalidRule(
+ { id: 1, condition, action: { type: "block" } },
+ expectedError,
+ isSchemaError
+ );
+ }
+ async function testInvalidAction(action, expectedError, isSchemaError) {
+ await testInvalidRule(
+ { id: 1, condition: {}, action },
+ expectedError,
+ isSchemaError
+ );
+ }
+
+ // The tests in this file merely verify whether rule registration and
+ // retrieval works. test_ext_dnr_testMatchOutcome.js checks rule evaluation.
+ async function testValidRule(rule) {
+ await dnr.updateSessionRules({ addRules: [rule] });
+
+ // Default rule with null for optional fields.
+ const expectedRule = dnrTestUtils.makeRuleOutput();
+ expectedRule.id = rule.id;
+ Object.assign(expectedRule.condition, rule.condition);
+ Object.assign(expectedRule.action, rule.action);
+ if (rule.action.redirect) {
+ expectedRule.action.redirect = {
+ extensionPath: null,
+ url: null,
+ transform: null,
+ regexSubstitution: null,
+ ...rule.action.redirect,
+ };
+ if (rule.action.redirect.transform) {
+ expectedRule.action.redirect.transform = {
+ scheme: null,
+ username: null,
+ password: null,
+ host: null,
+ port: null,
+ path: null,
+ query: null,
+ queryTransform: null,
+ fragment: null,
+ ...rule.action.redirect.transform,
+ };
+ if (rule.action.redirect.transform.queryTransform) {
+ const qt = {
+ removeParams: null,
+ addOrReplaceParams: null,
+ ...rule.action.redirect.transform.queryTransform,
+ };
+ if (qt.addOrReplaceParams) {
+ qt.addOrReplaceParams = qt.addOrReplaceParams.map(v => ({
+ key: null,
+ value: null,
+ replaceOnly: false,
+ ...v,
+ }));
+ }
+ expectedRule.action.redirect.transform.queryTransform = qt;
+ }
+ }
+ }
+ if (rule.action.requestHeaders) {
+ expectedRule.action.requestHeaders = rule.action.requestHeaders.map(
+ h => ({ header: null, operation: null, value: null, ...h })
+ );
+ }
+ if (rule.action.responseHeaders) {
+ expectedRule.action.responseHeaders = rule.action.responseHeaders.map(
+ h => ({ header: null, operation: null, value: null, ...h })
+ );
+ }
+
+ browser.test.assertDeepEq(
+ [expectedRule],
+ await dnr.getSessionRules(),
+ "Rule should be valid"
+ );
+
+ await dnr.updateSessionRules({ removeRuleIds: [rule.id] });
+ }
+ async function testValidCondition(condition) {
+ await testValidRule({ id: 1, condition, action: { type: "block" } });
+ }
+ async function testValidAction(action) {
+ await testValidRule({ id: 1, condition: {}, action });
+ }
+
+ Object.assign(dnrTestUtils, {
+ testInvalidRule,
+ testInvalidCondition,
+ testInvalidAction,
+ testValidRule,
+ testValidCondition,
+ testValidAction,
+ });
+ return dnrTestUtils;
+}
+
+async function runAsDNRExtension({ background, unloadTestAtEnd = true }) {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${background})((${makeDnrTestUtils})())`,
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ if (unloadTestAtEnd) {
+ await extension.unload();
+ }
+ return extension;
+}
+
+add_task(async function register_and_retrieve_session_rules() {
+ let extension = await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const dnr = browser.declarativeNetRequest;
+ // Rules input to updateSessionRules:
+ const RULE_1234_IN = dnrTestUtils.makeRuleInput(1234);
+ const RULE_4321_IN = dnrTestUtils.makeRuleInput(4321);
+ const RULE_9001_IN = dnrTestUtils.makeRuleInput(9001);
+ // Rules expected to be returned by getSessionRules:
+ const RULE_1234_OUT = dnrTestUtils.makeRuleOutput(1234);
+ const RULE_4321_OUT = dnrTestUtils.makeRuleOutput(4321);
+ const RULE_9001_OUT = dnrTestUtils.makeRuleOutput(9001);
+
+ await dnr.updateSessionRules({
+ // Deliberately rule 4321 before 1234, see next getSessionRules test.
+ addRules: [RULE_4321_IN, RULE_1234_IN],
+ removeRuleIds: [1234567890], // Invalid rules should be ignored.
+ });
+ browser.test.assertDeepEq(
+ // Order is same as the original input.
+ [RULE_4321_OUT, RULE_1234_OUT],
+ await dnr.getSessionRules(),
+ "getSessionRules() returns all registered session rules"
+ );
+
+ await browser.test.assertRejects(
+ dnr.updateSessionRules({
+ addRules: [RULE_9001_IN, RULE_1234_IN],
+ removeRuleIds: [RULE_4321_IN.id],
+ }),
+ "Duplicate rule ID: 1234",
+ "updateSessionRules of existing rule without removeRuleIds should fail"
+ );
+ browser.test.assertDeepEq(
+ [RULE_4321_OUT, RULE_1234_OUT],
+ await dnr.getSessionRules(),
+ "session rules should not be changed if an error has occurred"
+ );
+
+ // From [4321,1234] to [1234,9001,4321]; 4321 moves to the end because
+ // the rule is deleted before inserted, NOT updated in-place.
+ await dnr.updateSessionRules({
+ addRules: [RULE_9001_IN, RULE_4321_IN],
+ removeRuleIds: [RULE_4321_IN.id],
+ });
+ browser.test.assertDeepEq(
+ [RULE_1234_OUT, RULE_9001_OUT, RULE_4321_OUT],
+ await dnr.getSessionRules(),
+ "existing session rule ID can be re-used for a new rule"
+ );
+
+ await dnr.updateSessionRules({
+ removeRuleIds: [RULE_1234_IN.id, RULE_4321_IN.id, RULE_9001_IN.id],
+ });
+ browser.test.assertDeepEq(
+ [],
+ await dnr.getSessionRules(),
+ "deleted all rules"
+ );
+
+ browser.test.notifyPass();
+ },
+ unloadTestAtEnd: false,
+ });
+
+ const realExtension = extension.extension;
+ Assert.ok(
+ ExtensionDNR.getRuleManager(realExtension, /* createIfMissing= */ false),
+ "Rule manager exists before unload"
+ );
+ await extension.unload();
+ Assert.ok(
+ !ExtensionDNR.getRuleManager(realExtension, /* createIfMissing= */ false),
+ "Rule manager erased after unload"
+ );
+});
+
+add_task(async function validate_resourceTypes() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const {
+ testInvalidCondition,
+ testInvalidRule,
+ testValidRule,
+ testValidCondition,
+ } = dnrTestUtils;
+
+ await testInvalidCondition(
+ { resourceTypes: ["font", "image"], excludedResourceTypes: ["image"] },
+ "resourceTypes and excludedResourceTypes should not overlap"
+ );
+ await testInvalidCondition(
+ { resourceTypes: [], excludedResourceTypes: ["image"] },
+ /resourceTypes: Array requires at least 1 items; you have 0/,
+ /* isSchemaError */ true
+ );
+ await testValidCondition({
+ resourceTypes: ["font"],
+ excludedResourceTypes: ["image"],
+ });
+ await testValidCondition({
+ resourceTypes: ["font"],
+ excludedResourceTypes: [],
+ });
+
+ // Validation specific to allowAllRequests
+ await testInvalidRule(
+ {
+ id: 1,
+ condition: {},
+ action: { type: "allowAllRequests" },
+ },
+ "An allowAllRequests rule must have a non-empty resourceTypes array"
+ );
+ await testInvalidRule(
+ {
+ id: 1,
+ condition: { resourceTypes: [] },
+ action: { type: "allowAllRequests" },
+ },
+ /resourceTypes: Array requires at least 1 items; you have 0/,
+ /* isSchemaError */ true
+ );
+ await testInvalidRule(
+ {
+ id: 1,
+ condition: { resourceTypes: ["main_frame", "image"] },
+ action: { type: "allowAllRequests" },
+ },
+ "An allowAllRequests rule may only include main_frame/sub_frame in resourceTypes"
+ );
+ await testValidRule({
+ id: 1,
+ condition: { resourceTypes: ["main_frame"] },
+ action: { type: "allowAllRequests" },
+ });
+ await testValidRule({
+ id: 1,
+ condition: { resourceTypes: ["sub_frame"] },
+ action: { type: "allowAllRequests" },
+ });
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function validate_requestMethods() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testInvalidCondition, testValidCondition } = dnrTestUtils;
+
+ await testInvalidCondition(
+ { requestMethods: ["get"], excludedRequestMethods: ["post", "get"] },
+ "requestMethods and excludedRequestMethods should not overlap"
+ );
+ await testInvalidCondition(
+ { requestMethods: [] },
+ /requestMethods: Array requires at least 1 items; you have 0/,
+ /* isSchemaError */ true
+ );
+ await testInvalidCondition(
+ { requestMethods: ["GET"] },
+ "request methods must be in lower case"
+ );
+ await testInvalidCondition(
+ { excludedRequestMethods: ["PUT"] },
+ "request methods must be in lower case"
+ );
+ await testValidCondition({ excludedRequestMethods: [] });
+ await testValidCondition({
+ requestMethods: ["get", "head"],
+ excludedRequestMethods: ["post"],
+ });
+ await testValidCondition({
+ requestMethods: ["connect", "delete", "options", "patch", "put", "xxx"],
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function validate_tabIds() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testInvalidCondition, testValidCondition } = dnrTestUtils;
+
+ await testInvalidCondition(
+ { tabIds: [1], excludedTabIds: [1] },
+ "tabIds and excludedTabIds should not overlap"
+ );
+ await testInvalidCondition(
+ { tabIds: [] },
+ /tabIds: Array requires at least 1 items; you have 0/,
+ /* isSchemaError */ true
+ );
+ await testValidCondition({ excludedTabIds: [] });
+ await testValidCondition({ tabIds: [-1, 0, 1], excludedTabIds: [2] });
+ await testValidCondition({ tabIds: [Number.MAX_SAFE_INTEGER] });
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function validate_domains() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testInvalidCondition, testValidCondition } = dnrTestUtils;
+
+ await testInvalidCondition(
+ { requestDomains: [] },
+ /requestDomains: Array requires at least 1 items; you have 0/,
+ /* isSchemaError */ true
+ );
+ await testInvalidCondition(
+ { initiatorDomains: [] },
+ /initiatorDomains: Array requires at least 1 items; you have 0/,
+ /* isSchemaError */ true
+ );
+ // The include and exclude overlaps, but the validator doesn't reject it:
+ await testValidCondition({
+ requestDomains: ["example.com"],
+ excludedRequestDomains: ["example.com"],
+ initiatorDomains: ["example.com"],
+ excludedInitiatorDomains: ["example.com"],
+ });
+ await testValidCondition({
+ excludedRequestDomains: [],
+ excludedInitiatorDomains: [],
+ });
+
+ // "null" is valid as a way to match an opaque initiator.
+ await testInvalidCondition(
+ { requestDomains: [null] },
+ /requestDomains\.0: Expected string instead of null/,
+ /* isSchemaError */ true
+ );
+ await testValidCondition({ requestDomains: ["null"] });
+
+ // IPv4 adress should be 4 digits separated by a dot.
+ await testInvalidCondition(
+ { requestDomains: ["1.2"] },
+ /requestDomains\.0: Error: Invalid domain 1.2/,
+ /* isSchemaError */ true
+ );
+ await testValidCondition({ requestDomains: ["0.0.1.2"] });
+
+ // IPv6 should be wrapped in brackets.
+ await testInvalidCondition(
+ { requestDomains: ["::1"] },
+ /requestDomains\.0: Error: Invalid domain ::1/,
+ /* isSchemaError */ true
+ );
+ // IPv6 addresses cannot contain dots.
+ await testInvalidCondition(
+ { requestDomains: ["[::ffff:127.0.0.1]"] },
+ /requestDomains\.0: Error: Invalid domain \[::ffff:127\.0\.0\.1\]/,
+ /* isSchemaError */ true
+ );
+ await testValidCondition({
+ // "[::ffff:7f00:1]" is the canonical form of "[::ffff:127.0.0.1]".
+ requestDomains: ["[::1]", "[::ffff:7f00:1]"],
+ });
+
+ // International Domain Names should be punycode-encoded.
+ await testInvalidCondition(
+ { requestDomains: ["straß.de"] },
+ /requestDomains\.0: Error: Invalid domain straß.de/,
+ /* isSchemaError */ true
+ );
+ await testValidCondition({ requestDomains: ["xn--stra-yna.de"] });
+
+ // Domain may not contain a port.
+ await testInvalidCondition(
+ { requestDomains: ["a.com:1234"] },
+ /requestDomains\.0: Error: Invalid domain a.com:1234/,
+ /* isSchemaError */ true
+ );
+ // Upper case is not canonical.
+ await testInvalidCondition(
+ { requestDomains: ["UPPERCASE"] },
+ /requestDomains\.0: Error: Invalid domain UPPERCASE/,
+ /* isSchemaError */ true
+ );
+ // URL encoded is not canonical.
+ await testInvalidCondition(
+ { requestDomains: ["ex%61mple.com"] },
+ /requestDomains\.0: Error: Invalid domain ex%61mple.com/,
+ /* isSchemaError */ true
+ );
+
+ // Verify that the validation is applied to all domain-related keys.
+ for (let domainsKey of [
+ "initiatorDomains",
+ "excludedInitiatorDomains",
+ "requestDomains",
+ "excludedRequestDomains",
+ ]) {
+ await testInvalidCondition(
+ { [domainsKey]: [""] },
+ new RegExp(String.raw`${domainsKey}\.0: Error: Invalid domain \)`),
+ /* isSchemaError */ true
+ );
+ }
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// Basic urlFilter validation; test_ext_dnr_urlFilter.js has more tests.
+add_task(async function validate_urlFilter() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testInvalidCondition, testValidCondition } = dnrTestUtils;
+
+ await testInvalidCondition(
+ { urlFilter: "", regexFilter: "" },
+ "urlFilter and regexFilter are mutually exclusive"
+ );
+
+ await testInvalidCondition(
+ { urlFilter: 0 },
+ /urlFilter: Expected string instead of 0/,
+ /* isSchemaError */ true
+ );
+ await testInvalidCondition(
+ { urlFilter: "" },
+ "urlFilter should not be an empty string"
+ );
+ await testInvalidCondition(
+ { urlFilter: "||*" },
+ "urlFilter should not start with '||*'" // should use '*' instead.
+ );
+ await testInvalidCondition(
+ { urlFilter: "||*/" },
+ "urlFilter should not start with '||*'" // should use '*' instead.
+ );
+ await testInvalidCondition(
+ { urlFilter: "straß.de" },
+ "urlFilter should not contain non-ASCII characters"
+ );
+ await testValidCondition({ urlFilter: "xn--stra-yna.de" });
+ await testValidCondition({ urlFilter: "||xn--stra-yna.de/" });
+
+ // The following are all logically equivalent to "||*" (and ""), but are
+ // considered valid in the DNR API implemented/documented by Chrome.
+ await testValidCondition({ urlFilter: "*" });
+ await testValidCondition({ urlFilter: "****************" });
+ await testValidCondition({ urlFilter: "||" });
+ await testValidCondition({ urlFilter: "|" });
+ await testValidCondition({ urlFilter: "|*|" });
+ await testValidCondition({ urlFilter: "^" });
+ await testValidCondition({ urlFilter: null });
+
+ await testValidCondition({ urlFilter: "||example^" });
+ await testValidCondition({ urlFilter: "||example.com" });
+ await testValidCondition({ urlFilter: "||example.com/index^" });
+ await testValidCondition({ urlFilter: ".gif|" });
+ await testValidCondition({ urlFilter: "|https:" });
+ await testValidCondition({ urlFilter: "|https:*" });
+ await testValidCondition({ urlFilter: "e" });
+ await testValidCondition({ urlFilter: "%80" });
+ await testValidCondition({ urlFilter: "*e*" }); // FYI: same as just "e".
+ await testValidCondition({ urlFilter: "*e*|" }); // FYI: same as just "e".
+
+ let validchars = "";
+ for (let i = 0; i < 0x80; ++i) {
+ validchars += String.fromCharCode(i);
+ }
+ await testValidCondition({ urlFilter: validchars });
+ // Confirming that 0x80 and up is invalid.
+ await testInvalidCondition(
+ { urlFilter: "\x80" },
+ "urlFilter should not contain non-ASCII characters"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// Basic regexFilter validation; test_ext_dnr_regexFilter.js has more tests.
+add_task(async function validate_regexFilter() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testInvalidCondition, testValidCondition } = dnrTestUtils;
+
+ // This check is duplicated in validate_urlFilter.
+ await testInvalidCondition(
+ { urlFilter: "", regexFilter: "" },
+ "urlFilter and regexFilter are mutually exclusive"
+ );
+
+ await testInvalidCondition(
+ { regexFilter: /regex/ },
+ /regexFilter: Expected string instead of \{\}/,
+ /* isSchemaError */ true
+ );
+
+ await testInvalidCondition(
+ { regexFilter: "" },
+ "regexFilter should not be an empty string"
+ );
+ await testInvalidCondition(
+ { regexFilter: "*" },
+ "regexFilter is not a valid regular expression"
+ );
+ await testValidCondition(
+ { regexFilter: "^https://example\\.com\\/" },
+ "regexFilter with valid regexp should be accepted"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function validate_actions() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testInvalidAction, testValidAction, testValidRule } =
+ dnrTestUtils;
+
+ await testValidAction({ type: "allow" });
+ // Note: allowAllRequests is already covered in validate_resourceTypes
+ await testValidAction({ type: "block" });
+ await testValidAction({ type: "upgradeScheme" });
+ await testValidAction({ type: "block" });
+
+ // redirect actions, invalid cases
+ await testInvalidAction(
+ { type: "redirect" },
+ "A redirect rule must have a non-empty action.redirect object"
+ );
+ await testInvalidAction(
+ { type: "redirect", redirect: {} },
+ "A redirect rule must have a non-empty action.redirect object"
+ );
+ await testInvalidAction(
+ { type: "redirect", redirect: { extensionPath: "/", url: "http://a" } },
+ "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive"
+ );
+ await testInvalidAction(
+ { type: "redirect", redirect: { extensionPath: "", url: "http://a" } },
+ "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive"
+ );
+ await testInvalidAction(
+ {
+ type: "redirect",
+ redirect: { regexSubstitution: "", transform: {} },
+ },
+ "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive"
+ );
+ await testInvalidAction(
+ {
+ type: "redirect",
+ redirect: { regexSubstitution: "x", transform: {}, url: "http://a" },
+ },
+ "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive"
+ );
+ await testInvalidAction(
+ {
+ type: "redirect",
+ redirect: {
+ url: "http://a",
+ extensionPath: "/",
+ transform: {},
+ regexSubstitution: "http://a",
+ },
+ },
+ "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive"
+ );
+ await testInvalidAction(
+ { type: "redirect", redirect: { extensionPath: "" } },
+ "redirect.extensionPath should start with a '/'"
+ );
+ await testInvalidAction(
+ {
+ type: "redirect",
+ redirect: { extensionPath: browser.runtime.getURL("/") },
+ },
+ "redirect.extensionPath should start with a '/'"
+ );
+ await testInvalidAction(
+ { type: "redirect", redirect: { url: "javascript:" } },
+ /Access denied for URL javascript:/,
+ /* isSchemaError */ true
+ );
+ await testInvalidAction(
+ { type: "redirect", redirect: { url: "JAVASCRIPT:// Hmmm" } },
+ /Access denied for URL javascript:\/\/ Hmmm/,
+ /* isSchemaError */ true
+ );
+ await testInvalidAction(
+ { type: "redirect", redirect: { url: "about:addons" } },
+ /Access denied for URL about:addons/,
+ /* isSchemaError */ true
+ );
+ // TODO bug 1622986: allow redirects to data:-URLs.
+ await testInvalidAction(
+ { type: "redirect", redirect: { url: "data:," } },
+ /Access denied for URL data:,/,
+ /* isSchemaError */ true
+ );
+ await testInvalidAction(
+ { type: "redirect", redirect: { regexSubstitution: "http:///" } },
+ "redirect.regexSubstitution requires the regexFilter condition to be specified"
+ );
+
+ // redirect actions, valid cases
+ await testValidAction({
+ type: "redirect",
+ redirect: { extensionPath: "/foo.txt" },
+ });
+ await testValidAction({
+ type: "redirect",
+ redirect: { url: "https://example.com/" },
+ });
+ await testValidAction({
+ type: "redirect",
+ redirect: { url: browser.runtime.getURL("/") },
+ });
+ await testValidAction({
+ type: "redirect",
+ redirect: { transform: {} },
+ });
+ // redirect.transform is validated in validate_action_redirect_transform.
+ await testValidRule({
+ id: 1,
+ condition: { regexFilter: ".+" },
+ action: {
+ type: "redirect",
+ redirect: { regexSubstitution: "http://example.com/" },
+ },
+ });
+ // ^ redirect.regexSubstitution is tested by test_ext_dnr_regexFilter.js.
+
+ // modifyHeaders actions, invalid cases
+ await testInvalidAction(
+ { type: "modifyHeaders" },
+ "A modifyHeaders rule must have a non-empty requestHeaders or modifyHeaders list"
+ );
+ await testInvalidAction(
+ { type: "modifyHeaders", requestHeaders: [] },
+ /requestHeaders: Array requires at least 1 items; you have 0/,
+ /* isSchemaError */ true
+ );
+ await testInvalidAction(
+ { type: "modifyHeaders", responseHeaders: [] },
+ /responseHeaders: Array requires at least 1 items; you have 0/,
+ /* isSchemaError */ true
+ );
+ await testInvalidAction(
+ {
+ type: "modifyHeaders",
+ requestHeaders: [{ header: "", operation: "remove" }],
+ },
+ "header must be non-empty"
+ );
+ await testInvalidAction(
+ {
+ type: "modifyHeaders",
+ responseHeaders: [{ header: "", operation: "remove" }],
+ },
+ "header must be non-empty"
+ );
+ await testInvalidAction(
+ {
+ type: "modifyHeaders",
+ responseHeaders: [{ header: "x", operation: "append" }],
+ },
+ "value is required for operations append/set"
+ );
+ await testInvalidAction(
+ {
+ type: "modifyHeaders",
+ responseHeaders: [{ header: "x", operation: "set" }],
+ },
+ "value is required for operations append/set"
+ );
+ await testInvalidAction(
+ {
+ type: "modifyHeaders",
+ responseHeaders: [{ header: "x", operation: "remove", value: "x" }],
+ },
+ "value must not be provided for operation remove"
+ );
+ await testInvalidAction(
+ {
+ type: "modifyHeaders",
+ responseHeaders: [{ header: "x", operation: "REMOVE", value: "x" }],
+ },
+ /operation: Invalid enumeration value "REMOVE"/,
+ /* isSchemaError */ true
+ );
+
+ // modifyHeaders actions, valid cases
+ await testValidAction({
+ type: "modifyHeaders",
+ requestHeaders: [{ header: "x", operation: "set", value: "x" }],
+ });
+ await testValidAction({
+ type: "modifyHeaders",
+ responseHeaders: [{ header: "x", operation: "set", value: "x" }],
+ });
+ await testValidAction({
+ type: "modifyHeaders",
+ requestHeaders: [{ header: "y", operation: "set", value: "y" }],
+ responseHeaders: [{ header: "z", operation: "set", value: "z" }],
+ });
+ await testValidAction({
+ type: "modifyHeaders",
+ requestHeaders: [
+ { header: "reqh", operation: "set", value: "b" },
+ // Note: contrary to Chrome, we support "append" for requestHeaders:
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1797404#c1
+ { header: "reqh", operation: "append", value: "b" },
+ { header: "reqh", operation: "remove" },
+ ],
+ responseHeaders: [
+ { header: "resh", operation: "set", value: "b" },
+ { header: "resh", operation: "append", value: "b" },
+ { header: "resh", operation: "remove" },
+ ],
+ });
+
+ await testInvalidAction(
+ { type: "MODIFYHEADERS" },
+ /type: Invalid enumeration value "MODIFYHEADERS"/,
+ /* isSchemaError */ true
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// This test task only verifies that a redirect transform is validated upon
+// registration. A transform can result in an invalid redirect despite passing
+// validation (see e.g. VERY_LONG_STRING below).
+// test_ext_dnr_redirect_transform.js will test the behavior of such cases.
+add_task(async function validate_action_redirect_transform() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testInvalidAction, testValidAction } = dnrTestUtils;
+
+ const GENERIC_TRANSFORM_ERROR =
+ "redirect.transform does not describe a valid URL transformation";
+
+ const testValidTransform = transform =>
+ testValidAction({ type: "redirect", redirect: { transform } });
+ const testInvalidTransform = (transform, expectedError, isSchemaError) =>
+ testInvalidAction(
+ { type: "redirect", redirect: { transform } },
+ expectedError ?? GENERIC_TRANSFORM_ERROR,
+ isSchemaError
+ );
+
+ // Maximum length of a UTL is 1048576 (network.standard-url.max-length).
+ // Since URLs have other characters (separators), using VERY_LONG_STRING
+ // anywhere in a transform should be rejected. Note that this is mainly
+ // to verify that there is some bounds check on the URL. It is possible
+ // to generate a transform that is borderline valid at validation time,
+ // but invalid when applied to an existing longer URL.
+ const VERY_LONG_STRING = "x".repeat(1048576);
+
+ // An empty transformation is still valid.
+ await testValidTransform({});
+
+ // redirect.transform.scheme
+ await testValidTransform({ scheme: "http" });
+ await testValidTransform({ scheme: "https" });
+ await testValidTransform({ scheme: "moz-extension" });
+ await testInvalidTransform(
+ { scheme: "HTTPS" },
+ /scheme: Invalid enumeration value "HTTPS"/,
+ /* isSchemaError */ true
+ );
+ await testInvalidTransform(
+ { scheme: "javascript" },
+ /scheme: Invalid enumeration value "javascript"/,
+ /* isSchemaError */ true
+ );
+ // "ftp" is unsupported because support for it was dropped in Firefox.
+ // Chrome documents "ftp" as a supported scheme, but in practice it does
+ // not do anything useful, because it cannot handle ftp schemes either.
+ await testInvalidTransform(
+ { scheme: "ftp" },
+ /scheme: Invalid enumeration value "ftp"/,
+ /* isSchemaError */ true
+ );
+
+ // redirect.transform.host
+ await testValidTransform({ host: "example.com" });
+ await testValidTransform({ host: "example.com." });
+ await testValidTransform({ host: "localhost" });
+ await testValidTransform({ host: "127.0.0.1" });
+ await testValidTransform({ host: "[::1]" });
+ await testValidTransform({ host: "." });
+ await testValidTransform({ host: "straß.de" });
+ await testValidTransform({ host: "xn--stra-yna.de" });
+ await testInvalidTransform({ host: "::1" }); // Invalid IPv6.
+ await testInvalidTransform({ host: "[]" }); // Invalid IPv6.
+ await testInvalidTransform({ host: "/" }); // Invalid host
+ await testInvalidTransform({ host: " a" }); // Invalid host
+ await testInvalidTransform({ host: "foo:1234" }); // Port not allowed.
+ await testInvalidTransform({ host: "foo:" }); // Port sep not allowed.
+ await testInvalidTransform({ host: "" }); // Host cannot be empty.
+ await testInvalidTransform({ host: VERY_LONG_STRING });
+
+ // redirect.transform.port
+ await testValidTransform({ port: "" }); // empty = strip port.
+ await testValidTransform({ port: "0" });
+ await testValidTransform({ port: "0700" });
+ await testValidTransform({ port: "65535" });
+ const PORT_ERR = "redirect.transform.port should be empty or an integer";
+ await testInvalidTransform({ port: "65536" }, GENERIC_TRANSFORM_ERROR);
+ await testInvalidTransform({ port: " 0" }, PORT_ERR);
+ await testInvalidTransform({ port: "0 " }, PORT_ERR);
+ await testInvalidTransform({ port: "0." }, PORT_ERR);
+ await testInvalidTransform({ port: "0x1" }, PORT_ERR);
+ await testInvalidTransform({ port: "1.2" }, PORT_ERR);
+ await testInvalidTransform({ port: "-1" }, PORT_ERR);
+ await testInvalidTransform({ port: "a" }, PORT_ERR);
+ // A naive implementation of `host = hostname + ":" + port` could be
+ // misinterpreted as an IPv6 address. Verify that this is not the case.
+ await testInvalidTransform({ host: "[::1", port: "2]" }, PORT_ERR);
+ await testInvalidTransform({ port: VERY_LONG_STRING }, PORT_ERR);
+
+ // redirect.transform.path
+ await testValidTransform({ path: "" }); // empty = strip path.
+ await testValidTransform({ path: "/slash" });
+ await testValidTransform({ path: "/ref#ok" }); // # will be escaped.
+ await testValidTransform({ path: "/\n\t\x00" }); // Will all be escaped.
+ // A path should start with a '/', but the implementation works fine
+ // without it, and Chrome doesn't require it either.
+ await testValidTransform({ path: "noslash" });
+ await testValidTransform({ path: "http://example.com/" });
+ await testInvalidTransform({ path: VERY_LONG_STRING });
+
+ // redirect.transform.query
+ await testValidTransform({ query: "" }); // empty = strip query.
+ await testValidTransform({ query: "?suffix" });
+ await testValidTransform({ query: "?ref#ok" }); // # will be escaped.
+ await testValidTransform({ query: "?\n\t\x00" }); // Will all be escaped.
+ await testInvalidTransform(
+ { query: "noquestionmark" },
+ "redirect.transform.query should be empty or start with a '?'"
+ );
+ await testInvalidTransform({ query: "?" + VERY_LONG_STRING });
+
+ // redirect.transform.queryTransform
+ await testInvalidTransform(
+ { query: "", queryTransform: {} },
+ "redirect.transform.query and redirect.transform.queryTransform are mutually exclusive"
+ );
+ await testValidTransform({ queryTransform: {} });
+ await testValidTransform({ queryTransform: { removeParams: [] } });
+ await testValidTransform({ queryTransform: { removeParams: ["x"] } });
+ await testValidTransform({ queryTransform: { addOrReplaceParams: [] } });
+ await testValidTransform({
+ queryTransform: {
+ addOrReplaceParams: [{ key: "k", value: "v" }],
+ },
+ });
+ await testValidTransform({
+ queryTransform: {
+ addOrReplaceParams: [{ key: "k", value: "v", replaceOnly: true }],
+ },
+ });
+ await testInvalidTransform({
+ queryTransform: {
+ addOrReplaceParams: [{ key: "k", value: VERY_LONG_STRING }],
+ },
+ });
+ await testInvalidTransform(
+ {
+ queryTransform: {
+ addOrReplaceParams: [{ key: "k" }],
+ },
+ },
+ /addOrReplaceParams\.0: Property "value" is required/,
+ /* isSchemaError */ true
+ );
+ await testInvalidTransform(
+ {
+ queryTransform: {
+ addOrReplaceParams: [{ value: "v" }],
+ },
+ },
+ /addOrReplaceParams\.0: Property "key" is required/,
+ /* isSchemaError */ true
+ );
+
+ // redirect.transform.fragment
+ await testValidTransform({ fragment: "" }); // empty = strip fragment.
+ await testValidTransform({ fragment: "#suffix" });
+ await testValidTransform({ fragment: "#\n\t\x00" }); // will be escaped.
+ await testInvalidTransform(
+ { fragment: "nohash" },
+ "redirect.transform.fragment should be empty or start with a '#'"
+ );
+ await testInvalidTransform({ fragment: "#" + VERY_LONG_STRING });
+
+ // redirect.transform.username
+ await testValidTransform({ username: "" }); // empty = strip username.
+ await testValidTransform({ username: "username" });
+ await testValidTransform({ username: "@:" }); // will be escaped.
+ await testInvalidTransform({ username: VERY_LONG_STRING });
+
+ // redirect.transform.password
+ await testValidTransform({ password: "" }); // empty = strip password.
+ await testValidTransform({ password: "pass" });
+ await testValidTransform({ password: "@:" }); // will be escaped.
+ await testInvalidTransform({ password: VERY_LONG_STRING });
+
+ // All together:
+ await testValidTransform({
+ scheme: "http",
+ username: "a",
+ password: "b",
+ host: "c",
+ port: "12345",
+ path: "/d",
+ query: "?e",
+ queryTransform: null,
+ fragment: "#f",
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function session_rules_total_rule_limit() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES } =
+ browser.declarativeNetRequest;
+
+ let inputRules = [];
+ let nextRuleId = 1;
+ for (let i = 0; i < MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES; ++i) {
+ inputRules.push(dnrTestUtils.makeRuleInput(nextRuleId++));
+ }
+ let excessRule = dnrTestUtils.makeRuleInput(nextRuleId++);
+
+ browser.test.log(`Should be able to add ${inputRules.length} rules.`);
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: inputRules,
+ });
+
+ browser.test.assertEq(
+ inputRules.length,
+ (await browser.declarativeNetRequest.getSessionRules()).length,
+ "Added up to MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES session rules"
+ );
+
+ await browser.test.assertRejects(
+ browser.declarativeNetRequest.updateSessionRules({
+ addRules: [excessRule],
+ }),
+ `Number of rules in ruleset "_session" exceeds MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES.`,
+ "Should not accept more than MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES rules"
+ );
+
+ await browser.test.assertRejects(
+ browser.declarativeNetRequest.updateSessionRules({
+ removeRuleIds: [inputRules[0].id],
+ addRules: [inputRules[0], excessRule],
+ }),
+ `Number of rules in ruleset "_session" exceeds MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES.`,
+ "Removing one rule is not enough to make space for two rules"
+ );
+
+ browser.test.log("Should be able to replace one rule while at the limit");
+ await browser.declarativeNetRequest.updateSessionRules({
+ removeRuleIds: [inputRules[0].id],
+ addRules: [excessRule],
+ });
+
+ browser.test.log("Should be able to remove many rules, even at quota");
+ await browser.declarativeNetRequest.updateSessionRules({
+ // Note: inputRules[0].id was already removed, but that's fine.
+ removeRuleIds: inputRules.map(r => r.id),
+ });
+
+ browser.test.assertDeepEq(
+ [dnrTestUtils.makeRuleOutput(excessRule.id)],
+ await browser.declarativeNetRequest.getSessionRules(),
+ "Expected one rule after removing all-but-one-rule"
+ );
+
+ await browser.test.assertRejects(
+ browser.declarativeNetRequest.updateSessionRules({
+ addRules: inputRules,
+ }),
+ `Number of rules in ruleset "_session" exceeds MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES.`,
+ "Should not be able to add MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES when there is already a rule"
+ );
+
+ await browser.declarativeNetRequest.updateSessionRules({
+ removeRuleIds: [excessRule.id],
+ });
+ browser.test.assertDeepEq(
+ [],
+ await browser.declarativeNetRequest.getSessionRules(),
+ "Removed last remaining rule"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_startup_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_startup_cache.js
new file mode 100644
index 0000000000..bcb05eec23
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_startup_cache.js
@@ -0,0 +1,651 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { Schemas } = ChromeUtils.importESModule(
+ "resource://gre/modules/Schemas.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs",
+ ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetters(this, {
+ aomStartup: [
+ "@mozilla.org/addons/addon-manager-startup;1",
+ "amIAddonManagerStartup",
+ ],
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+Services.scriptloader.loadSubScript(
+ Services.io.newFileURI(do_get_file("head_dnr.js")).spec,
+ this
+);
+
+const EXT_ID = "test-dnr-store-startup-cache@test-extension";
+const TEMP_EXT_ID = "test-dnr-store-temporarily-installed@test-extension";
+
+// Test rulesets should include fields that are special cased during Rule object deserialization
+// from the plain objects loaded from the StartupCache data objects.
+// In particular regexFilter are included to make sure that RuleValidator.deserializeRule is
+// internally compiling the regexFilter and storing it into the RuleCondition instance as expected
+// (implicitly asserted internally by the test helper assertDNRStoreData in head_dnr.js).
+const RULESET_1_DATA = [
+ getDNRRule({
+ id: 1,
+ action: { type: "allow" },
+ condition: { resourceTypes: ["main_frame"], regexFilter: "http://from/$" },
+ }),
+ getDNRRule({
+ id: 2,
+ action: { type: "allow" },
+ condition: {
+ resourceTypes: ["main_frame"],
+ regexFilter: "http://from2/$",
+ isUrlFilterCaseSensitive: true,
+ },
+ }),
+];
+const RULESET_2_DATA = [
+ getDNRRule({
+ action: { type: "block" },
+ condition: { resourceTypes: ["main_frame", "script"] },
+ }),
+];
+
+function getDNRExtension({
+ id = EXT_ID,
+ version = "1.0",
+ useAddonManager = "permanent",
+ background,
+ rule_resources,
+ declarative_net_request,
+ files,
+}) {
+ // Omit declarative_net_request if rule_resources isn't defined
+ // (because declarative_net_request fails the manifest validation
+ // if rule_resources is missing).
+ const dnr = rule_resources ? { rule_resources } : undefined;
+
+ return {
+ background,
+ useAddonManager,
+ manifest: {
+ manifest_version: 3,
+ version,
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ // Needed to make sure the upgraded extension will have the same id and
+ // same uuid (which is mapped based on the extension id).
+ browser_specific_settings: {
+ gecko: { id },
+ },
+ declarative_net_request: declarative_net_request
+ ? { ...declarative_net_request, ...(dnr ?? {}) }
+ : dnr,
+ },
+ files,
+ };
+}
+
+add_setup(async () => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.feedback", true);
+
+ setupTelemetryForTests();
+
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_dnr_startup_cache_save_and_load() {
+ resetTelemetryData();
+
+ const rule_resources = [
+ {
+ id: "ruleset_1",
+ enabled: true,
+ path: "ruleset_1.json",
+ },
+ {
+ id: "ruleset_2",
+ enabled: false,
+ path: "ruleset_2.json",
+ },
+ ];
+ const files = {
+ "ruleset_1.json": JSON.stringify(RULESET_1_DATA),
+ "ruleset_2.json": JSON.stringify(RULESET_2_DATA),
+ };
+
+ let dnrStore = ExtensionDNRStore._getStoreForTesting();
+ let sandboxStoreSpies = sinon.createSandbox();
+ const spyScheduleCacheDataSave = sandboxStoreSpies.spy(
+ dnrStore,
+ "scheduleCacheDataSave"
+ );
+
+ const extension = ExtensionTestUtils.loadExtension(
+ getDNRExtension({ rule_resources, files })
+ );
+
+ const temporarilyInstalledExt = ExtensionTestUtils.loadExtension(
+ getDNRExtension({
+ id: TEMP_EXT_ID,
+ useAddonManager: "temporary",
+ rule_resources,
+ files,
+ })
+ );
+
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ },
+ ],
+ "before any test extensions have been loaded"
+ );
+
+ await temporarilyInstalledExt.startup();
+ await extension.startup();
+ info(
+ "Wait for DNR initialization completed for the temporarily installed extension"
+ );
+ await ExtensionDNR.ensureInitialized(temporarilyInstalledExt.extension);
+ info(
+ "Wait for DNR initialization completed for the permanently installed extension"
+ );
+ await ExtensionDNR.ensureInitialized(extension.extension);
+
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: 2,
+ },
+ ],
+ "after two test extensions have been loaded"
+ );
+
+ Assert.equal(
+ spyScheduleCacheDataSave.callCount,
+ 1,
+ "Expect ExtensionDNRStore scheduleCacheDataSave method to have been called once"
+ );
+
+ sandboxStoreSpies.restore();
+
+ const extUUID = extension.uuid;
+ const { cacheFile } = dnrStore.getFilePaths(extUUID);
+
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset_1: getSchemaNormalizedRules(extension, RULESET_1_DATA),
+ });
+
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "startupCacheWriteTime",
+ mirroredName: "WEBEXT_DNR_STARTUPCACHE_WRITE_MS",
+ mirroredType: "histogram",
+ },
+ {
+ metric: "startupCacheWriteSize",
+ mirroredName: "WEBEXT_DNR_STARTUPCACHE_WRITE_BYTES",
+ mirroredType: "histogram",
+ },
+ // Expected no startup cache file to be loaded or used for a newly installed extension.
+ {
+ metric: "startupCacheReadSize",
+ mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_BYTES",
+ mirroredType: "histogram",
+ },
+ {
+ metric: "startupCacheReadTime",
+ mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_MS",
+ mirroredType: "histogram",
+ },
+ {
+ metric: "startupCacheEntries",
+ label: "miss",
+ mirroredName: "extensions.apis.dnr.startup_cache_entries",
+ mirroredType: "keyedScalar",
+ },
+ {
+ metric: "startupCacheEntries",
+ label: "hit",
+ mirroredName: "extensions.apis.dnr.startup_cache_entries",
+ mirroredType: "keyedScalar",
+ },
+ ],
+ "on loading dnr rules for newly installed extension"
+ );
+ await dnrStore.waitSaveCacheDataForTesting();
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "startupCacheWriteTime",
+ mirroredName: "WEBEXT_DNR_STARTUPCACHE_WRITE_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: 1,
+ },
+ {
+ metric: "startupCacheWriteSize",
+ mirroredName: "WEBEXT_DNR_STARTUPCACHE_WRITE_BYTES",
+ mirroredType: "histogram",
+ expectedSamplesCount: 1,
+ },
+ ],
+ "after writing DNR startup cache data to disk"
+ );
+
+ ok(
+ await IOUtils.exists(cacheFile),
+ "Expect the DNR store startupCache file exist"
+ );
+
+ const assertDNRStoreDataLoadOnStartup = async ({
+ expectLoadedFromCache,
+ expectClearLastUpdateTagPref,
+ }) => {
+ info(
+ `Mock browser restart and assert DNR rules ${
+ expectLoadedFromCache ? "NOT " : ""
+ }going through Schemas.normalize`
+ );
+ await AddonTestUtils.promiseShutdownManager();
+ // Recreate the DNR store to more easily mock its initial state after a browser restart.
+ dnrStore = ExtensionDNRStore._recreateStoreForTesting();
+ const StoreData = ExtensionDNRStore._getStoreDataClassForTesting();
+
+ let sandbox = sinon.createSandbox();
+ const schemasNormalizeSpy = sandbox.spy(Schemas, "normalize");
+ const ruleValidatorAddRulesSpy = sandbox.spy(
+ ExtensionDNR.RuleValidator.prototype,
+ "addRules"
+ );
+ const deserializeRuleSpy = sandbox.spy(
+ ExtensionDNR.RuleValidator,
+ "deserializeRule"
+ );
+ const clearLastUpdateTagPrefSpy = sandbox.spy(
+ StoreData,
+ "clearLastUpdateTagPref"
+ );
+ const scheduleCacheDataSaveSpy = sandbox.spy(
+ dnrStore,
+ "scheduleCacheDataSave"
+ );
+
+ resetTelemetryData();
+ await AddonTestUtils.promiseStartupManager();
+ await extension.awaitStartup();
+ await ExtensionDNR.ensureInitialized(extension.extension);
+
+ if (expectLoadedFromCache) {
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "startupCacheReadSize",
+ mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_BYTES",
+ mirroredType: "histogram",
+ expectedSamplesCount: 1,
+ },
+ {
+ metric: "startupCacheReadTime",
+ mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: 1,
+ },
+ ],
+ "after app startup and expected startup cache hit"
+ );
+ assertDNRTelemetryMetricsGetValueEq(
+ [
+ {
+ metric: "startupCacheEntries",
+ label: "hit",
+ expectedGetValue: 1,
+ mirroredName: "extensions.apis.dnr.startup_cache_entries",
+ mirroredType: "keyedScalar",
+ },
+ ],
+ "after app startup and expected startup cache hit"
+ );
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ },
+ {
+ metric: "startupCacheEntries",
+ label: "miss",
+ mirroredName: "extensions.apis.dnr.startup_cache_entries",
+ mirroredType: "keyedScalar",
+ },
+ ],
+ "after DNR store loaded startup cache data"
+ );
+ } else {
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: 1,
+ },
+ {
+ metric: "startupCacheReadSize",
+ mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_BYTES",
+ mirroredType: "histogram",
+ expectedSamplesCount: 1,
+ },
+ {
+ metric: "startupCacheReadTime",
+ mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: 1,
+ },
+ ],
+ "after app startup and expected startup cache miss"
+ );
+ assertDNRTelemetryMetricsGetValueEq(
+ [
+ {
+ metric: "startupCacheEntries",
+ label: "miss",
+ expectedGetValue: 1,
+ mirroredName: "extensions.apis.dnr.startup_cache_entries",
+ mirroredType: "keyedScalar",
+ },
+ ],
+ "after app startup and expected startup cache miss"
+ );
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "startupCacheEntries",
+ label: "hit",
+ mirroredName: "extensions.apis.dnr.startup_cache_entries",
+ mirroredType: "keyedScalar",
+ },
+ ],
+ "after DNR store loaded startup cache data"
+ );
+ }
+
+ Assert.equal(
+ scheduleCacheDataSaveSpy.called,
+ !expectLoadedFromCache,
+ "scheduleCacheDataSave to not be called when the extension DNR rules are initialized from startup cache data"
+ );
+
+ Assert.equal(
+ clearLastUpdateTagPrefSpy.callCount,
+ expectClearLastUpdateTagPref ? 1 : 0,
+ "Expect clearLastUpdateTagPrefSpy to have been called the expected number of times"
+ );
+ if (expectClearLastUpdateTagPref === true) {
+ Assert.ok(
+ clearLastUpdateTagPrefSpy.calledWith(extension.uuid),
+ "Expect clearLastUpdateTagPrefSpy to have been called with the test extension uuid"
+ );
+ }
+
+ Assert.equal(
+ schemasNormalizeSpy.calledWith(
+ sinon.match.any,
+ sinon.match("declarativeNetRequest.Rule"),
+ sinon.match.any
+ ),
+ !expectLoadedFromCache,
+ `Expect DNR rules to ${
+ expectLoadedFromCache ? "NOT " : ""
+ }be going through Schemas.normalize`
+ );
+
+ Assert.equal(
+ ruleValidatorAddRulesSpy.called,
+ !expectLoadedFromCache,
+ `Expect DNR rules to ${
+ expectLoadedFromCache ? "NOT " : ""
+ }be going through RuleValidator addRules`
+ );
+
+ Assert.equal(
+ deserializeRuleSpy.called,
+ expectLoadedFromCache,
+ `Expect RuleValidator.deserializeRule to ${
+ expectLoadedFromCache ? "NOT " : ""
+ }be called to convert StartupCache data back into Rule class instances`
+ );
+
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset_1: getSchemaNormalizedRules(extension, RULESET_1_DATA),
+ });
+
+ sandbox.restore();
+ };
+
+ const expectedLastUpdateTag = ExtensionDNRStore._getLastUpdateTag(
+ extension.uuid
+ );
+
+ Assert.ok(
+ typeof expectedLastUpdateTag == "string" && !!expectedLastUpdateTag.length,
+ `Expect lastUpdateTag for ${extension.id} to be set to a non empty string: ${expectedLastUpdateTag}`
+ );
+
+ Assert.equal(
+ ExtensionDNRStore._getLastUpdateTag(temporarilyInstalledExt.uuid),
+ null,
+ `Expect no lastUpdateTag value set for temporarily installed extensions`
+ );
+
+ await assertDNRStoreDataLoadOnStartup({
+ expectLoadedFromCache: true,
+ expectClearLastUpdateTagPref: false,
+ });
+
+ {
+ const { buffer } = await IOUtils.read(cacheFile);
+ const decodedData = aomStartup.decodeBlob(buffer);
+ Assert.equal(
+ expectedLastUpdateTag,
+ decodedData?.cacheData.get(extension.uuid).lastUpdateTag,
+ "Expect cacheData entry's lastUpdateTag to match the value stored in the related pref"
+ );
+ Assert.equal(
+ decodedData?.cacheData.has(temporarilyInstalledExt.uuid),
+ false,
+ "Expect no cache data entry for temporarily installed extensions"
+ );
+
+ info("Confirm startupCache data dropped if last tag pref value mismatches");
+ ExtensionDNRStore._storeLastUpdateTag(
+ extension.uuid,
+ "mismatching-tag-value"
+ );
+ Assert.notEqual(
+ ExtensionDNRStore._getLastUpdateTag(extension.uuid),
+ decodedData?.cacheData.get(extension.uuid).lastUpdateTag,
+ "Expect cacheData.lastDNRStoreUpdateTag to NOT match the tampered value stored in the related pref"
+ );
+ }
+
+ await assertDNRStoreDataLoadOnStartup({
+ expectLoadedFromCache: false,
+ expectClearLastUpdateTagPref: true,
+ });
+
+ info(
+ "Verify that startupCache data mismatching with the StoreData schema version is being dropped"
+ );
+ await dnrStore.waitSaveCacheDataForTesting();
+ await assertDNRStoreDataLoadOnStartup({
+ expectLoadedFromCache: true,
+ expectClearLastUpdateTagPref: false,
+ });
+
+ {
+ info("Tamper the StoreData version in the startupCache data");
+ const { buffer } = await IOUtils.read(cacheFile);
+ const decodedData = aomStartup.decodeBlob(buffer);
+ decodedData.cacheData.get(extUUID).schemaVersion = -1;
+ await IOUtils.write(
+ cacheFile,
+ new Uint8Array(aomStartup.encodeBlob(decodedData))
+ );
+ }
+
+ await assertDNRStoreDataLoadOnStartup({
+ expectLoadedFromCache: false,
+ expectClearLastUpdateTagPref: true,
+ });
+
+ info(
+ "Verify that startupCache data mismatching with the extension version is being dropped"
+ );
+ await dnrStore.waitSaveCacheDataForTesting();
+ await assertDNRStoreDataLoadOnStartup({
+ expectLoadedFromCache: true,
+ expectClearLastUpdateTagPref: false,
+ });
+
+ {
+ info("Tamper the extension version in the startupCache data");
+ const { buffer } = await IOUtils.read(cacheFile);
+ const decodedData = aomStartup.decodeBlob(buffer);
+ decodedData.cacheData.get(extUUID).extVersion = "0.1";
+ await IOUtils.write(
+ cacheFile,
+ new Uint8Array(aomStartup.encodeBlob(decodedData))
+ );
+ }
+ await assertDNRStoreDataLoadOnStartup({
+ expectLoadedFromCache: false,
+ expectClearLastUpdateTagPref: true,
+ });
+
+ await extension.unload();
+
+ Assert.equal(
+ ExtensionDNRStore._getLastUpdateTag(extension.uuid),
+ null,
+ "LastUpdateTag pref should have been removed after addon uninstall"
+ );
+});
+
+add_task(async function test_detect_and_reschedule_save_cache_on_new_changes() {
+ const rule_resources = [
+ {
+ id: "ruleset_1",
+ enabled: true,
+ path: "ruleset_1.json",
+ },
+ ];
+ const files = {
+ "ruleset_1.json": JSON.stringify(RULESET_1_DATA),
+ };
+
+ let dnrStore = ExtensionDNRStore._getStoreForTesting();
+ let sandboxStore = sinon.createSandbox();
+ const spyScheduleCacheDataSave = sandboxStore.spy(
+ dnrStore,
+ "scheduleCacheDataSave"
+ );
+
+ let extension;
+ const tamperedLastUpdateTag = Services.uuid.generateUUID().toString();
+ let resolvePromiseSaveCacheRescheduled;
+ let promiseSaveCacheRescheduled = new Promise(resolve => {
+ resolvePromiseSaveCacheRescheduled = resolve;
+ });
+ const realDetectStartupCacheDataChanged =
+ dnrStore.detectStartupCacheDataChanged.bind(dnrStore);
+ const stubDetectCacheDataChanges = sandboxStore.stub(
+ dnrStore,
+ "detectStartupCacheDataChanged"
+ );
+
+ stubDetectCacheDataChanges.callsFake(seenLastUpdateTags => {
+ const extData = dnrStore._data.get(extension.extension.uuid);
+ Assert.ok(extData, "Got StoreData instance for the test extension");
+ Assert.ok(
+ typeof extData.lastUpdateTag === "string" &&
+ !!extData.lastUpdateTag.length,
+ "Expect a non empty lastUpdateTag assigned to the extension StoreData"
+ );
+ Assert.deepEqual(
+ Array.from(seenLastUpdateTags),
+ [extData.lastUpdateTag],
+ "Expects the extension storeData lastUpdateTag to have been seen"
+ );
+ if (stubDetectCacheDataChanges.callCount == 1) {
+ Assert.notEqual(
+ extData.lastUpdateTag,
+ tamperedLastUpdateTag,
+ "New tampered lastUpdateTag should not be equal to the one already set"
+ );
+ extData.lastUpdateTag = tamperedLastUpdateTag;
+ Assert.equal(
+ realDetectStartupCacheDataChanged(seenLastUpdateTags),
+ true,
+ "Expect dnrStore.detectStartupCacheDataChanged to detect a change"
+ );
+ return true;
+ }
+ Assert.equal(
+ realDetectStartupCacheDataChanged(seenLastUpdateTags),
+ false,
+ "Expect dnrStore.detectStartupCacheDataChanged to NOT have detected any change"
+ );
+
+ Promise.resolve().then(resolvePromiseSaveCacheRescheduled);
+ return false;
+ });
+
+ extension = ExtensionTestUtils.loadExtension(
+ getDNRExtension({
+ id: "test-reschedule-save-on-detected-changes@test",
+ rule_resources,
+ files,
+ })
+ );
+
+ await extension.startup();
+ info(
+ "Wait for DNR initialization completed for the permanently installed extension"
+ );
+ await ExtensionDNR.ensureInitialized(extension.extension);
+ info("Wait for the saveCacheDataNow task to have been rescheduled");
+ await promiseSaveCacheRescheduled;
+
+ Assert.equal(
+ spyScheduleCacheDataSave.callCount,
+ 2,
+ "Expect ExtensionDNRStore scheduleCacheDataSave method to have been called twice"
+ );
+ Assert.equal(
+ stubDetectCacheDataChanges.callCount,
+ 2,
+ "Expect ExtensionDNRStore detectStartupCacheDataChanged method to have been called twice"
+ );
+
+ sandboxStore.restore();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_static_rules.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_static_rules.js
new file mode 100644
index 0000000000..19c869b149
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_static_rules.js
@@ -0,0 +1,1849 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs",
+ ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs",
+ ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+Services.scriptloader.loadSubScript(
+ Services.io.newFileURI(do_get_file("head_dnr.js")).spec,
+ this
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerPathHandler("/", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.write("response from server");
+});
+
+function backgroundWithDNRAPICallHandlers() {
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ let result;
+ switch (msg) {
+ case "getEnabledRulesets":
+ result = await browser.declarativeNetRequest.getEnabledRulesets();
+ break;
+ case "getAvailableStaticRuleCount":
+ result =
+ await browser.declarativeNetRequest.getAvailableStaticRuleCount();
+ break;
+ case "testMatchOutcome":
+ result = await browser.declarativeNetRequest
+ .testMatchOutcome(...args)
+ .catch(err =>
+ browser.test.fail(
+ `Unexpected rejection from testMatchOutcome call: ${err}`
+ )
+ );
+ break;
+ case "updateEnabledRulesets":
+ // Run (one or more than one concurrently) updateEnabledRulesets calls
+ // and report back the results.
+ result = await Promise.all(
+ args.map(arg => {
+ return browser.declarativeNetRequest
+ .updateEnabledRulesets(arg)
+ .catch(err => {
+ return { rejectedWithErrorMessage: err.message };
+ });
+ })
+ );
+ break;
+ default:
+ browser.test.fail(`Unexpected test message: ${msg}`);
+ return;
+ }
+
+ browser.test.sendMessage(`${msg}:done`, result);
+ });
+
+ browser.test.sendMessage("bgpage:ready");
+}
+
+function getDNRExtension({
+ id = "test-dnr-static-rules@test-extension",
+ version = "1.0",
+ background = backgroundWithDNRAPICallHandlers,
+ useAddonManager = "permanent",
+ rule_resources,
+ declarative_net_request,
+ files,
+}) {
+ // Omit declarative_net_request if rule_resources isn't defined
+ // (because declarative_net_request fails the manifest validation
+ // if rule_resources is missing).
+ const dnr = rule_resources ? { rule_resources } : undefined;
+
+ return {
+ background,
+ useAddonManager,
+ manifest: {
+ manifest_version: 3,
+ version,
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ // Needed to make sure the upgraded extension will have the same id and
+ // same uuid (which is mapped based on the extension id).
+ browser_specific_settings: {
+ gecko: { id },
+ },
+ declarative_net_request: declarative_net_request
+ ? { ...declarative_net_request, ...(dnr ?? {}) }
+ : dnr,
+ },
+ files,
+ };
+}
+
+const assertDNRTestMatchOutcome = async (
+ { extension, testRequest, expected },
+ assertMessage
+) => {
+ extension.sendMessage("testMatchOutcome", testRequest);
+ Assert.deepEqual(
+ expected,
+ await extension.awaitMessage("testMatchOutcome:done"),
+ assertMessage ??
+ "Got the expected matched rules from testMatchOutcome API call"
+ );
+};
+
+const assertDNRGetAvailableStaticRuleCount = async (
+ extensionTestWrapper,
+ expectedCount,
+ assertMessage
+) => {
+ extensionTestWrapper.sendMessage("getAvailableStaticRuleCount");
+ Assert.deepEqual(
+ await extensionTestWrapper.awaitMessage("getAvailableStaticRuleCount:done"),
+ expectedCount,
+ assertMessage ??
+ "Got the expected count value from dnr.getAvailableStaticRuleCount API method"
+ );
+};
+
+const assertDNRGetEnabledRulesets = async (
+ extensionTestWrapper,
+ expectedRulesetIds
+) => {
+ extensionTestWrapper.sendMessage("getEnabledRulesets");
+ Assert.deepEqual(
+ await extensionTestWrapper.awaitMessage("getEnabledRulesets:done"),
+ expectedRulesetIds,
+ "Got the expected enabled ruleset ids from dnr.getEnabledRulesets API method"
+ );
+};
+
+add_setup(async () => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.feedback", true);
+
+ setupTelemetryForTests();
+
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_load_static_rules() {
+ const ruleset1Data = [
+ getDNRRule({
+ action: { type: "allow" },
+ condition: { resourceTypes: ["main_frame"] },
+ }),
+ ];
+ const ruleset2Data = [
+ getDNRRule({
+ action: { type: "block" },
+ condition: { resourceTypes: ["main_frame", "script"] },
+ }),
+ ];
+
+ const rule_resources = [
+ {
+ id: "ruleset_1",
+ enabled: true,
+ path: "ruleset_1.json",
+ },
+ {
+ id: "ruleset_2",
+ enabled: true,
+ path: "ruleset_2.json",
+ },
+ {
+ id: "ruleset_3",
+ enabled: false,
+ path: "ruleset_3.json",
+ },
+ ];
+ const files = {
+ // Missing ruleset_3.json on purpose.
+ "ruleset_1.json": JSON.stringify(ruleset1Data),
+ "ruleset_2.json": JSON.stringify(ruleset2Data),
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(
+ getDNRExtension({ rule_resources, files })
+ );
+
+ await extension.startup();
+
+ const extUUID = extension.uuid;
+
+ await extension.awaitMessage("bgpage:ready");
+
+ const dnrStore = ExtensionDNRStore._getStoreForTesting();
+
+ info("Verify DNRStore data for the test extension");
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_1", "ruleset_2"]);
+
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data),
+ ruleset_2: getSchemaNormalizedRules(extension, ruleset2Data),
+ });
+
+ info("Verify matched rules using testMatchOutcome");
+ const testRequestMainFrame = {
+ url: "https://example.com/some-dummy-url",
+ type: "main_frame",
+ };
+ const testRequestScript = {
+ url: "https://example.com/some-dummy-url.js",
+ type: "script",
+ };
+
+ await assertDNRTestMatchOutcome(
+ {
+ extension,
+ testRequest: testRequestMainFrame,
+ expected: {
+ matchedRules: [{ ruleId: 1, rulesetId: "ruleset_1" }],
+ },
+ },
+ "Expect ruleset_1 to be matched on the main-frame test request"
+ );
+ await assertDNRTestMatchOutcome(
+ {
+ extension,
+ testRequest: testRequestScript,
+ expected: {
+ matchedRules: [{ ruleId: 1, rulesetId: "ruleset_2" }],
+ },
+ },
+ "Expect ruleset_2 to be matched on the script test request"
+ );
+
+ info("Verify DNRStore data persisted on disk for the test extension");
+ // The data will not be stored on disk until something is being changed
+ // from what was already available in the manifest and so in this
+ // test we save manually (a test for the updateEnabledRulesets will
+ // take care of asserting that the data has been stored automatically
+ // on disk when it is meant to).
+ await dnrStore.save(extension.extension);
+
+ const { storeFile } = dnrStore.getFilePaths(extUUID);
+
+ ok(await IOUtils.exists(storeFile), `DNR storeFile ${storeFile} found`);
+
+ // force deleting the data stored in memory to confirm if it being loaded again from
+ // the files stored on disk.
+ dnrStore._data.delete(extUUID);
+ dnrStore._dataPromises.delete(extUUID);
+
+ info("Verify the expected DNRStore data persisted on disk is loaded back");
+ const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+ );
+ const addon = await AddonManager.getAddonByID(extension.id);
+ await addon.disable();
+
+ ok(
+ !dnrStore._dataPromises.has(extUUID),
+ "DNR store read data promise cleared after the extension has been disabled"
+ );
+ ok(
+ !dnrStore._data.has(extUUID),
+ "DNR store data cleared from memory after the extension has been disabled"
+ );
+
+ await addon.enable();
+ await extension.awaitMessage("bgpage:ready");
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_1", "ruleset_2"]);
+
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data),
+ ruleset_2: getSchemaNormalizedRules(extension, ruleset2Data),
+ });
+
+ info("Verify matched rules using testMatchOutcome");
+ await assertDNRTestMatchOutcome(
+ {
+ extension,
+ testRequest: testRequestMainFrame,
+ expected: {
+ matchedRules: [{ ruleId: 1, rulesetId: "ruleset_1" }],
+ },
+ },
+ "Expect ruleset_1 to be matched on the main-frame test request"
+ );
+
+ info("Verify enabled static rules updated on addon updates");
+ await extension.upgrade(
+ getDNRExtension({
+ version: "2.0",
+ rule_resources: [
+ {
+ id: "ruleset_1",
+ enabled: false,
+ path: "ruleset_1.json",
+ },
+ {
+ id: "ruleset_2",
+ enabled: true,
+ path: "ruleset_2.json",
+ },
+ ],
+ files: {
+ "ruleset_2.json": JSON.stringify(ruleset2Data),
+ },
+ })
+ );
+ await extension.awaitMessage("bgpage:ready");
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_2"]);
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset_2: getSchemaNormalizedRules(extension, ruleset2Data),
+ });
+
+ info("Verify matched rules using testMatchOutcome");
+ await assertDNRTestMatchOutcome(
+ {
+ extension,
+ testRequest: testRequestMainFrame,
+ expected: {
+ matchedRules: [{ ruleId: 1, rulesetId: "ruleset_2" }],
+ },
+ },
+ "Expect ruleset_2 to be matched on the main-frame test request"
+ );
+
+ info(
+ "Verify enabled static rules updated on addon updates even if version in the manifest did not change"
+ );
+ await extension.upgrade(
+ getDNRExtension({
+ rule_resources: [
+ {
+ id: "ruleset_1",
+ enabled: true,
+ path: "ruleset_1.json",
+ },
+ {
+ id: "ruleset_2",
+ enabled: false,
+ path: "ruleset_2.json",
+ },
+ ],
+ files: {
+ "ruleset_1.json": JSON.stringify(ruleset1Data),
+ },
+ })
+ );
+ await extension.awaitMessage("bgpage:ready");
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]);
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data),
+ });
+
+ info("Verify matched rules using testMatchOutcome");
+ await assertDNRTestMatchOutcome(
+ {
+ extension,
+ testRequest: testRequestMainFrame,
+ expected: {
+ matchedRules: [{ ruleId: 1, rulesetId: "ruleset_1" }],
+ },
+ },
+ "Expect ruleset_2 to be matched on the main-script test request"
+ );
+
+ info(
+ "Verify updated addon version with no static rules but declarativeNetRequest permission granted"
+ );
+ await extension.upgrade(
+ getDNRExtension({
+ version: "3.0",
+ rule_resources: undefined,
+ files: {},
+ })
+ );
+ await extension.awaitMessage("bgpage:ready");
+ await assertDNRGetEnabledRulesets(extension, []);
+ await assertDNRStoreData(dnrStore, extension, {});
+
+ info("Verify matched rules using testMatchOutcome");
+ await assertDNRTestMatchOutcome(
+ {
+ extension,
+ testRequest: testRequestScript,
+ expected: {
+ matchedRules: [],
+ },
+ },
+ "Expect no match on the script test request on test extension without no static rules"
+ );
+
+ info("Verify store file removed on addon uninstall");
+ await extension.unload();
+
+ ok(
+ !dnrStore._dataPromises.has(extUUID),
+ "DNR store read data promise cleared after the extension has been unloaded"
+ );
+ ok(
+ !dnrStore._data.has(extUUID),
+ "DNR store data cleared from memory after the extension has been unloaded"
+ );
+
+ ok(
+ !(await IOUtils.exists(storeFile)),
+ `DNR storeFile ${storeFile} removed on addon uninstalled`
+ );
+});
+
+add_task(async function test_load_from_corrupted_data() {
+ const ruleset1Data = [
+ getDNRRule({
+ action: { type: "allow" },
+ condition: { resourceTypes: ["main_frame"] },
+ }),
+ ];
+
+ const rule_resources = [
+ {
+ id: "ruleset_1",
+ enabled: true,
+ path: "ruleset_1.json",
+ },
+ ];
+
+ const files = {
+ "ruleset_1.json": JSON.stringify(ruleset1Data),
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(
+ getDNRExtension({ rule_resources, files })
+ );
+
+ await extension.startup();
+
+ const extUUID = extension.uuid;
+
+ await extension.awaitMessage("bgpage:ready");
+
+ const dnrStore = ExtensionDNRStore._getStoreForTesting();
+
+ info("Verify DNRStore data for the test extension");
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]);
+
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data),
+ });
+
+ info("Verify DNRStore data after loading corrupted store data");
+ await dnrStore.save(extension.extension);
+
+ const { storeFile } = dnrStore.getFilePaths(extUUID);
+ ok(await IOUtils.exists(storeFile), `DNR storeFile ${storeFile} found`);
+
+ const nonCorruptedData = await IOUtils.readJSON(storeFile, {
+ decompress: true,
+ });
+
+ async function testLoadedRulesAfterDataCorruption({
+ name,
+ asyncWriteStoreFile,
+ expectedCorruptFile,
+ }) {
+ info(`Tempering DNR store data: ${name}`);
+
+ await extension.addon.disable();
+
+ ok(
+ !dnrStore._dataPromises.has(extUUID),
+ "DNR store read data promise cleared after the extension has been disabled"
+ );
+ ok(
+ !dnrStore._data.has(extUUID),
+ "DNR store data cleared from memory after the extension has been disabled"
+ );
+
+ // Make sure we remove a previous corrupt file in case there is one from a previous run.
+ await IOUtils.remove(expectedCorruptFile, { ignoreAbsent: true });
+
+ await asyncWriteStoreFile();
+
+ await extension.addon.enable();
+ await extension.awaitMessage("bgpage:ready");
+
+ info("Verify DNRStore data for the test extension");
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]);
+
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data),
+ });
+
+ await TestUtils.waitForCondition(
+ () => IOUtils.exists(`${expectedCorruptFile}`),
+ `Wait for the "${expectedCorruptFile}" file to have been created`
+ );
+ }
+
+ await testLoadedRulesAfterDataCorruption({
+ name: "invalid lz4 header",
+ asyncWriteStoreFile: () =>
+ IOUtils.writeUTF8(storeFile, "not an lz4 compressed file", {
+ compress: false,
+ }),
+ expectedCorruptFile: `${storeFile}.corrupt`,
+ });
+
+ await testLoadedRulesAfterDataCorruption({
+ name: "invalid json data",
+ asyncWriteStoreFile: () =>
+ IOUtils.writeUTF8(storeFile, "invalid json data", { compress: true }),
+ expectedCorruptFile: `${storeFile}-1.corrupt`,
+ });
+
+ await testLoadedRulesAfterDataCorruption({
+ name: "empty json data",
+ asyncWriteStoreFile: () =>
+ IOUtils.writeUTF8(storeFile, "{}", { compress: true }),
+ expectedCorruptFile: `${storeFile}-2.corrupt`,
+ });
+
+ await testLoadedRulesAfterDataCorruption({
+ name: "invalid staticRulesets property type",
+ asyncWriteStoreFile: () =>
+ IOUtils.writeUTF8(
+ storeFile,
+ JSON.stringify({
+ schemaVersion: nonCorruptedData.schemaVersion,
+ extVersion: extension.extension.version,
+ staticRulesets: "Not an array",
+ }),
+ { compress: true }
+ ),
+ expectedCorruptFile: `${storeFile}-3.corrupt`,
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_ruleset_validation() {
+ const invalidRulesetIdCases = [
+ {
+ description: "empty ruleset id",
+ rule_resources: [
+ {
+ // Invalid empty ruleset id.
+ id: "",
+ path: "ruleset_0.json",
+ enabled: true,
+ },
+ ],
+ expected: [
+ // Validation error emitted from the manifest schema validation.
+ {
+ message: /rule_resources\.0\.id: String "" must match/,
+ },
+ ],
+ },
+ {
+ description: "invalid ruleset id starting with '_'",
+ rule_resources: [
+ {
+ // Invalid empty ruleset id.
+ id: "_invalid_ruleset_id",
+ path: "ruleset_0.json",
+ enabled: true,
+ },
+ ],
+ expected: [
+ // Validation error emitted from the manifest schema validation.
+ {
+ message:
+ /rule_resources\.0\.id: String "_invalid_ruleset_id" must match/,
+ },
+ ],
+ },
+ {
+ description: "duplicated ruleset ids",
+ rule_resources: [
+ {
+ id: "ruleset_2",
+ path: "ruleset_2.json",
+ enabled: true,
+ },
+ {
+ // Duplicated ruleset id.
+ id: "ruleset_2",
+ path: "duplicated_ruleset_2.json",
+ enabled: true,
+ },
+ {
+ id: "ruleset_3",
+ path: "ruleset_3.json",
+ enabled: true,
+ },
+ {
+ // Other duplicated ruleset id.
+ id: "ruleset_3",
+ path: "duplicated_ruleset_3.json",
+ enabled: true,
+ },
+ ],
+ // NOTE: this is currently a warning logged from onManifestEntry, and so it would actually
+ // fail in test harness due to the manifest warning, because it is too late at that point
+ // the addon is technically already starting at that point.
+ expectInstallFailed: false,
+ expected: [
+ {
+ message:
+ /declarative_net_request: Static ruleset ids should be unique.*: "ruleset_2" at index 1, "ruleset_3" at index 3/,
+ },
+ ],
+ },
+ {
+ description: "missing mandatory path",
+ rule_resources: [
+ {
+ // Missing mandatory path.
+ id: "ruleset_3",
+ enabled: true,
+ },
+ ],
+ expected: [
+ {
+ message: /rule_resources\.0: Property "path" is required/,
+ },
+ ],
+ },
+ {
+ description: "missing mandatory id",
+ rule_resources: [
+ {
+ // Missing mandatory id.
+ enabled: true,
+ path: "missing_ruleset_id.json",
+ },
+ ],
+ expected: [
+ {
+ message: /rule_resources\.0: Property "id" is required/,
+ },
+ ],
+ },
+ {
+ description: "duplicated ruleset path",
+ rule_resources: [
+ {
+ id: "ruleset_2",
+ path: "ruleset_2.json",
+ enabled: true,
+ },
+ {
+ // Duplicate path.
+ id: "ruleset_3",
+ path: "ruleset_2.json",
+ enabled: true,
+ },
+ ],
+ // NOTE: we couldn't get on agreement about making this a manifest validation error, apparently Chrome doesn't validate it and doesn't
+ // even report any warning, and so it is logged only as an informative warning but without triggering an install failure.
+ expectInstallFailed: false,
+ expected: [
+ {
+ message:
+ /declarative_net_request: Static rulesets paths are not unique.*: ".*ruleset_2.json" at index 1/,
+ },
+ ],
+ },
+ {
+ description: "missing mandatory enabled",
+ rule_resources: [
+ {
+ id: "ruleset_without_enabled",
+ path: "ruleset.json",
+ },
+ ],
+ expected: [
+ {
+ message: /rule_resources\.0: Property "enabled" is required/,
+ },
+ ],
+ },
+ {
+ description: "allows and warns additional properties",
+ declarative_net_request: {
+ unexpected_prop: true,
+ rule_resources: [
+ {
+ id: "ruleset1",
+ path: "ruleset1.json",
+ enabled: false,
+ unexpected_prop: true,
+ },
+ ],
+ },
+ expectInstallFailed: false,
+ expected: [
+ {
+ message:
+ /declarative_net_request.unexpected_prop: An unexpected property was found/,
+ },
+ {
+ message:
+ /rule_resources.0.unexpected_prop: An unexpected property was found/,
+ },
+ ],
+ },
+ {
+ description: "invalid ruleset JSON - unexpected comments",
+ rule_resources: [
+ {
+ id: "invalid_ruleset_with_comments",
+ path: "invalid_ruleset_with_comments.json",
+ enabled: true,
+ },
+ ],
+ files: {
+ "invalid_ruleset_with_comments.json":
+ "/* an unexpected inline comment */\n[]",
+ },
+ expectInstallFailed: false,
+ expected: [
+ {
+ message:
+ /Reading declarative_net_request .*invalid_ruleset_with_comments\.json: JSON.parse: unexpected character/,
+ },
+ ],
+ },
+ {
+ description: "invalid ruleset JSON - empty string",
+ rule_resources: [
+ {
+ id: "invalid_ruleset_emptystring",
+ path: "invalid_ruleset_emptystring.json",
+ enabled: true,
+ },
+ ],
+ files: {
+ "invalid_ruleset_emptystring.json": JSON.stringify(""),
+ },
+ expectInstallFailed: false,
+ expected: [
+ {
+ message:
+ /Reading declarative_net_request .*invalid_ruleset_emptystring\.json: rules file must contain an Array/,
+ },
+ ],
+ },
+ {
+ description: "invalid ruleset JSON - object",
+ rule_resources: [
+ {
+ id: "invalid_ruleset_object",
+ path: "invalid_ruleset_object.json",
+ enabled: true,
+ },
+ ],
+ files: {
+ "invalid_ruleset_object.json": JSON.stringify({}),
+ },
+ expectInstallFailed: false,
+ expected: [
+ {
+ message:
+ /Reading declarative_net_request .*invalid_ruleset_object\.json: rules file must contain an Array/,
+ },
+ ],
+ },
+ {
+ description: "invalid ruleset JSON - null",
+ rule_resources: [
+ {
+ id: "invalid_ruleset_null",
+ path: "invalid_ruleset_null.json",
+ enabled: true,
+ },
+ ],
+ files: {
+ "invalid_ruleset_null.json": JSON.stringify(null),
+ },
+ expectInstallFailed: false,
+ expected: [
+ {
+ message:
+ /Reading declarative_net_request .*invalid_ruleset_null\.json: rules file must contain an Array/,
+ },
+ ],
+ },
+ ];
+
+ for (const {
+ description,
+ declarative_net_request,
+ rule_resources,
+ files,
+ expected,
+ expectInstallFailed = true,
+ } of invalidRulesetIdCases) {
+ info(`Test manifest validation: ${description}`);
+ let extension = ExtensionTestUtils.loadExtension(
+ getDNRExtension({ rule_resources, declarative_net_request, files })
+ );
+
+ const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ if (expectInstallFailed) {
+ await Assert.rejects(
+ extension.startup(),
+ /Install failed/,
+ "Expected install to fail"
+ );
+ } else {
+ await extension.startup();
+ await extension.awaitMessage("bgpage:ready");
+ await extension.unload();
+ }
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ });
+
+ AddonTestUtils.checkMessages(messages, { expected });
+ }
+});
+
+add_task(async function test_updateEnabledRuleset_id_validation() {
+ const rule_resources = [
+ {
+ id: "ruleset_1",
+ enabled: true,
+ path: "ruleset_1.json",
+ },
+ {
+ id: "ruleset_2",
+ enabled: false,
+ path: "ruleset_2.json",
+ },
+ ];
+
+ const ruleset1Data = [
+ getDNRRule({
+ action: { type: "allow" },
+ condition: { resourceTypes: ["main_frame"] },
+ }),
+ ];
+ const ruleset2Data = [
+ getDNRRule({
+ action: { type: "block" },
+ condition: { resourceTypes: ["main_frame", "script"] },
+ }),
+ ];
+
+ const files = {
+ "ruleset_1.json": JSON.stringify(ruleset1Data),
+ "ruleset_2.json": JSON.stringify(ruleset2Data),
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(
+ getDNRExtension({ rule_resources, files })
+ );
+
+ await extension.startup();
+ await extension.awaitMessage("bgpage:ready");
+
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]);
+
+ const dnrStore = ExtensionDNRStore._getStoreForTesting();
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data),
+ });
+
+ const invalidStaticRulesetIds = [
+ // The following two are reserved for session and dynamic rules.
+ "_session",
+ "_dynamic",
+ "ruleset_non_existing",
+ ];
+
+ for (const invalidRSId of invalidStaticRulesetIds) {
+ extension.sendMessage(
+ "updateEnabledRulesets",
+ // Only in rulesets to be disabled.
+ { disableRulesetIds: [invalidRSId] },
+ // Only in rulesets to be enabled.
+ { enableRulesetIds: [invalidRSId] },
+ // In both rulesets to be enabled and disabled.
+ { disableRulesetIds: [invalidRSId], enableRulesetIds: [invalidRSId] },
+ // Along with existing rulesets (and expected the existing rulesets
+ // to stay unchanged due to the invalid ruleset ids.)
+ {
+ disableRulesetIds: [invalidRSId, "ruleset_1"],
+ enableRulesetIds: [invalidRSId, "ruleset_2"],
+ }
+ );
+ const [
+ resInDisable,
+ resInEnable,
+ resInEnableAndDisable,
+ resInSameRequestAsValid,
+ ] = await extension.awaitMessage("updateEnabledRulesets:done");
+ await Assert.rejects(
+ Promise.reject(resInDisable?.rejectedWithErrorMessage),
+ new RegExp(`Invalid ruleset id: "${invalidRSId}"`),
+ `Got the expected rejection on invalid ruleset id "${invalidRSId}" in disableRulesetIds`
+ );
+ await Assert.rejects(
+ Promise.reject(resInEnable?.rejectedWithErrorMessage),
+ new RegExp(`Invalid ruleset id: "${invalidRSId}"`),
+ `Got the expected rejection on invalid ruleset id "${invalidRSId}" in enableRulesetIds`
+ );
+ await Assert.rejects(
+ Promise.reject(resInEnableAndDisable?.rejectedWithErrorMessage),
+ new RegExp(`Invalid ruleset id: "${invalidRSId}"`),
+ `Got the expected rejection on invalid ruleset id "${invalidRSId}" in both enable/disableRulesetIds`
+ );
+ await Assert.rejects(
+ Promise.reject(resInSameRequestAsValid?.rejectedWithErrorMessage),
+ new RegExp(`Invalid ruleset id: "${invalidRSId}"`),
+ `Got the expected rejection on invalid ruleset id "${invalidRSId}" along with valid ruleset ids`
+ );
+ }
+
+ // Confirm that the expected rulesets didn't change neither.
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]);
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data),
+ });
+
+ // - List the same ruleset ids more than ones is expected to work and
+ // to be resulting in the same set of rules being enabled
+ // - Disabling and Enabling the same ruleset id should result in the
+ // ruleset being enabled.
+ await extension.sendMessage("updateEnabledRulesets", {
+ disableRulesetIds: [
+ "ruleset_1",
+ "ruleset_1",
+ "ruleset_2",
+ "ruleset_2",
+ "ruleset_2",
+ ],
+ enableRulesetIds: ["ruleset_2", "ruleset_2"],
+ });
+ Assert.deepEqual(
+ await extension.awaitMessage("updateEnabledRulesets:done"),
+ [undefined],
+ "Expect the updateEnabledRulesets to result successfully"
+ );
+
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_2"]);
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset_2: getSchemaNormalizedRules(extension, ruleset2Data),
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_getAvailableStaticRulesCountAndLimits() {
+ // NOTE: this test is going to load and validate the maximum amount of static rules
+ // that an extension can enable, which on slower builds (in particular in tsan builds,
+ // e.g. see Bug 1803801) have a higher chance that the test extension may have hit the
+ // idle timeout and being suspended by the time the test is going to trigger API method
+ // calls through test API events (which do not expect the lifetime of the event page).
+ Services.prefs.setBoolPref("extensions.background.idle.enabled", false);
+
+ const dnrStore = ExtensionDNRStore._getStoreForTesting();
+ const { GUARANTEED_MINIMUM_STATIC_RULES } = ExtensionDNRLimits;
+ equal(
+ typeof GUARANTEED_MINIMUM_STATIC_RULES,
+ "number",
+ "Expect GUARANTEED_MINIMUM_STATIC_RULES to be a number"
+ );
+
+ const availableStaticRulesCount = GUARANTEED_MINIMUM_STATIC_RULES;
+
+ const rule_resources = [
+ {
+ id: "ruleset_0",
+ path: "/ruleset_0.json",
+ enabled: true,
+ },
+ {
+ id: "ruleset_1",
+ path: "/ruleset_1.json",
+ enabled: true,
+ },
+ // A ruleset initially disabled (to make sure it doesn't count for the
+ // rules count limit).
+ {
+ id: "ruleset_disabled",
+ path: "/ruleset_disabled.json",
+ enabled: false,
+ },
+ // A ruleset including an invalid rule and valid rule.
+ {
+ id: "ruleset_withInvalid",
+ path: "/ruleset_withInvalid.json",
+ enabled: false,
+ },
+ // An empty ruleset (to make sure it can still be enabled/disabled just fine,
+ // e.g. in case on some browser version all rules are technically invalid).
+ {
+ id: "ruleset_empty",
+ path: "/ruleset_empty.json",
+ enabled: false,
+ },
+ ];
+
+ const files = {};
+ const rules = {};
+
+ const rulesetDisabledData = [getDNRRule({ id: 1 })];
+ const ruleValid = getDNRRule({ id: 2, action: { type: "allow" } });
+ const rulesetWithInvalidData = [
+ getDNRRule({ id: 1, action: { type: "invalid_action" } }),
+ ruleValid,
+ ];
+
+ rules.ruleset_0 = [getDNRRule({ id: 1 }), getDNRRule({ id: 2 })];
+
+ rules.ruleset_1 = [];
+ for (let i = 0; i < availableStaticRulesCount; i++) {
+ rules.ruleset_1.push(getDNRRule({ id: i + 1 }));
+ }
+
+ for (const [k, v] of Object.entries(rules)) {
+ files[`${k}.json`] = JSON.stringify(v);
+ }
+ files[`ruleset_disabled.json`] = JSON.stringify(rulesetDisabledData);
+ files[`ruleset_withInvalid.json`] = JSON.stringify(rulesetWithInvalidData);
+ files[`ruleset_empty.json`] = JSON.stringify([]);
+
+ const extension = ExtensionTestUtils.loadExtension(
+ getDNRExtension({
+ id: "dnr-getAvailable-count-@mochitest",
+ rule_resources,
+ files,
+ })
+ );
+
+ await extension.startup();
+ await extension.awaitMessage("bgpage:ready");
+
+ async function updateEnabledRulesets({ expectedErrorMessage, ...options }) {
+ // Note: options = { disableRulesetIds, enableRulesetIds }
+ extension.sendMessage("updateEnabledRulesets", options);
+ let [result] = await extension.awaitMessage("updateEnabledRulesets:done");
+ if (expectedErrorMessage) {
+ Assert.deepEqual(
+ result,
+ { rejectedWithErrorMessage: expectedErrorMessage },
+ "updateEnabledRulesets() should reject with the given error"
+ );
+ } else {
+ Assert.deepEqual(
+ result,
+ undefined,
+ "updateEnabledRulesets() should resolve without error"
+ );
+ }
+ }
+
+ const expectedEnabledRulesets = {};
+ expectedEnabledRulesets.ruleset_0 = getSchemaNormalizedRules(
+ extension,
+ rules.ruleset_0
+ );
+
+ info(
+ "Expect ruleset_1 to not be enabled because along with ruleset_0 exceeded the static rules count limit"
+ );
+ await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets);
+
+ await assertDNRGetAvailableStaticRuleCount(
+ extension,
+ availableStaticRulesCount - rules.ruleset_0.length,
+ "Got the available static rule count on ruleset_0 initially enabled"
+ );
+
+ // Try to enable ruleset_1 again from the API method.
+ await updateEnabledRulesets({
+ enableRulesetIds: ["ruleset_1"],
+ expectedErrorMessage: `Number of rules across all enabled static rulesets exceeds GUARANTEED_MINIMUM_STATIC_RULES if ruleset "ruleset_1" were to be enabled.`,
+ });
+
+ info(
+ "Expect ruleset_1 to not be enabled because still exceeded the static rules count limit"
+ );
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_0"]);
+ await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets);
+
+ await assertDNRGetAvailableStaticRuleCount(
+ extension,
+ availableStaticRulesCount - rules.ruleset_0.length,
+ "Got the available static rule count on ruleset_0 still the only one enabled"
+ );
+
+ await updateEnabledRulesets({
+ disableRulesetIds: ["ruleset_0"],
+ enableRulesetIds: ["ruleset_1"],
+ });
+
+ info("Expect ruleset_1 to be enabled along with disabling ruleset_0");
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]);
+ delete expectedEnabledRulesets.ruleset_0;
+ expectedEnabledRulesets.ruleset_1 = getSchemaNormalizedRules(
+ extension,
+ rules.ruleset_1
+ );
+ await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets, {
+ // Assert total amount of expected rules and only the first and last rule
+ // individually, to avoid generating a huge amount of logs and potential
+ // timeout failures on slower builds.
+ assertIndividualRules: false,
+ });
+
+ await assertDNRGetAvailableStaticRuleCount(
+ extension,
+ 0,
+ "Expect no additional static rules count available when ruleset_1 is enabled"
+ );
+
+ info(
+ "Expect ruleset_disabled to stay disabled because along with ruleset_1 exceeeds the limits"
+ );
+ await updateEnabledRulesets({
+ enableRulesetIds: ["ruleset_disabled"],
+ expectedErrorMessage: `Number of rules across all enabled static rulesets exceeds GUARANTEED_MINIMUM_STATIC_RULES if ruleset "ruleset_disabled" were to be enabled.`,
+ });
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]);
+ await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets, {
+ // Assert total amount of expected rules and only the first and last rule
+ // individually, to avoid generating a huge amount of logs and potential
+ // timeout failures on slower builds.
+ assertIndividualRules: false,
+ });
+ await assertDNRGetAvailableStaticRuleCount(
+ extension,
+ 0,
+ "Expect no additional static rules count available"
+ );
+
+ info("Expect ruleset_empty to be enabled despite having reached the limit");
+ await updateEnabledRulesets({
+ enableRulesetIds: ["ruleset_empty"],
+ });
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_1", "ruleset_empty"]);
+ await assertDNRStoreData(
+ dnrStore,
+ extension,
+ {
+ ...expectedEnabledRulesets,
+ ruleset_empty: [],
+ },
+ // Assert total amount of expected rules and only the first and last rule
+ // individually, to avoid generating a huge amount of logs and potential
+ // timeout failures on slower builds.
+ { assertIndividualRules: false }
+ );
+ await assertDNRGetAvailableStaticRuleCount(
+ extension,
+ 0,
+ "Expect no additional static rules count available"
+ );
+
+ info("Expect invalid rules to not be counted towards the limits");
+ await updateEnabledRulesets({
+ disableRulesetIds: ["ruleset_1", "ruleset_empty"],
+ enableRulesetIds: ["ruleset_withInvalid"],
+ });
+ await assertDNRGetEnabledRulesets(extension, ["ruleset_withInvalid"]);
+ await assertDNRStoreData(dnrStore, extension, {
+ // Only the valid rule has been actually loaded, and the invalid one
+ // ignored.
+ ruleset_withInvalid: [ruleValid],
+ });
+ await assertDNRGetAvailableStaticRuleCount(
+ extension,
+ availableStaticRulesCount - 1,
+ "Expect only valid rules to be counted"
+ );
+
+ await extension.unload();
+
+ Services.prefs.clearUserPref("extensions.background.idle.enabled");
+});
+
+add_task(async function test_static_rulesets_limits() {
+ const dnrStore = ExtensionDNRStore._getStoreForTesting();
+
+ const getRulesetManifestData = (rulesetNumber, enabled) => {
+ return {
+ id: `ruleset_${rulesetNumber}`,
+ enabled,
+ path: `ruleset_${rulesetNumber}.json`,
+ };
+ };
+ const {
+ MAX_NUMBER_OF_STATIC_RULESETS,
+ MAX_NUMBER_OF_ENABLED_STATIC_RULESETS,
+ } = ExtensionDNRLimits;
+
+ equal(
+ typeof MAX_NUMBER_OF_STATIC_RULESETS,
+ "number",
+ "Expect MAX_NUMBER_OF_STATIC_RULESETS to be a number"
+ );
+ equal(
+ typeof MAX_NUMBER_OF_ENABLED_STATIC_RULESETS,
+ "number",
+ "Expect MAX_NUMBER_OF_ENABLED_STATIC_RULESETS to be a number"
+ );
+ ok(
+ MAX_NUMBER_OF_STATIC_RULESETS > MAX_NUMBER_OF_ENABLED_STATIC_RULESETS,
+ "Expect MAX_NUMBER_OF_STATIC_RULESETS to be greater"
+ );
+
+ const rules = [getDNRRule()];
+
+ const rule_resources = [];
+ const files = {};
+ for (let i = 0; i < MAX_NUMBER_OF_STATIC_RULESETS + 1; i++) {
+ const enabled = i < MAX_NUMBER_OF_ENABLED_STATIC_RULESETS + 1;
+ files[`ruleset_${i}.json`] = JSON.stringify(rules);
+ rule_resources.push(getRulesetManifestData(i, enabled));
+ }
+
+ let extension = ExtensionTestUtils.loadExtension(
+ getDNRExtension({
+ rule_resources,
+ files,
+ })
+ );
+
+ const expectedEnabledRulesets = {};
+
+ const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ await extension.awaitMessage("bgpage:ready");
+
+ for (let i = 0; i < MAX_NUMBER_OF_ENABLED_STATIC_RULESETS; i++) {
+ expectedEnabledRulesets[`ruleset_${i}`] = getSchemaNormalizedRules(
+ extension,
+ rules
+ );
+ }
+
+ await assertDNRGetEnabledRulesets(
+ extension,
+ Array.from(Object.keys(expectedEnabledRulesets))
+ );
+ await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets);
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ // Warnings emitted from the manifest schema validation.
+ {
+ message:
+ /declarative_net_request: Static rulesets are exceeding the MAX_NUMBER_OF_STATIC_RULESETS limit/,
+ },
+ {
+ message:
+ /declarative_net_request: Enabled static rulesets are exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS limit .* "ruleset_10"/,
+ },
+ // Error reported on the browser console as part of loading enabled rulesets)
+ // on enabled rulesets being ignored because exceeding the limit.
+ {
+ message:
+ /Ignoring enabled static ruleset exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS .* "ruleset_10"/,
+ },
+ ],
+ });
+
+ info(
+ "Verify updateEnabledRulesets reject when the request is exceeding the enabled rulesets count limit"
+ );
+ extension.sendMessage("updateEnabledRulesets", {
+ disableRulesetIds: ["ruleset_0"],
+ enableRulesetIds: ["ruleset_10", "ruleset_11"],
+ });
+
+ await Assert.rejects(
+ extension.awaitMessage("updateEnabledRulesets:done").then(results => {
+ if (results[0].rejectedWithErrorMessage) {
+ return Promise.reject(new Error(results[0].rejectedWithErrorMessage));
+ }
+ return results[0];
+ }),
+ /updatedEnabledRulesets request is exceeding MAX_NUMBER_OF_ENABLED_STATIC_RULESETS/,
+ "Expected rejection on updateEnabledRulesets exceeting enabled rulesets count limit"
+ );
+
+ // Confirm that the expected rulesets didn't change neither.
+ await assertDNRGetEnabledRulesets(
+ extension,
+ Array.from(Object.keys(expectedEnabledRulesets))
+ );
+ await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets);
+
+ info(
+ "Verify updateEnabledRulesets applies the expected changes when resolves successfully"
+ );
+ extension.sendMessage(
+ "updateEnabledRulesets",
+ {
+ disableRulesetIds: ["ruleset_0"],
+ enableRulesetIds: ["ruleset_10"],
+ },
+ {
+ disableRulesetIds: ["ruleset_10"],
+ enableRulesetIds: ["ruleset_11"],
+ }
+ );
+ await extension.awaitMessage("updateEnabledRulesets:done");
+
+ // Expect ruleset_0 disabled, ruleset_10 to be enabled but then disabled by the
+ // second update queued after the first one, and ruleset_11 to be enabled.
+ delete expectedEnabledRulesets.ruleset_0;
+ expectedEnabledRulesets.ruleset_11 = getSchemaNormalizedRules(
+ extension,
+ rules
+ );
+
+ await assertDNRGetEnabledRulesets(
+ extension,
+ Array.from(Object.keys(expectedEnabledRulesets))
+ );
+ await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets);
+
+ // Ensure all changes were stored and reloaded from disk store and the
+ // DNR store update queue can accept new updates.
+ info("Verify static rules load and updates after extension is restarted");
+
+ // NOTE: promiseRestartManager will not be enough to make sure the
+ // DNR store data for the test extension is going to be loaded from
+ // the DNR startup cache file.
+ // See test_ext_dnr_startup_cache.js for a test case that more completely
+ // simulates ExtensionDNRStore initialization on browser restart.
+ await AddonTestUtils.promiseRestartManager();
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("bgpage:ready");
+ await assertDNRGetEnabledRulesets(
+ extension,
+ Array.from(Object.keys(expectedEnabledRulesets))
+ );
+ await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets);
+
+ extension.sendMessage("updateEnabledRulesets", {
+ disableRulesetIds: ["ruleset_11"],
+ });
+ await extension.awaitMessage("updateEnabledRulesets:done");
+ delete expectedEnabledRulesets.ruleset_11;
+ await assertDNRGetEnabledRulesets(
+ extension,
+ Array.from(Object.keys(expectedEnabledRulesets))
+ );
+ await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets);
+
+ await extension.unload();
+});
+
+add_task(async function test_tabId_conditions_invalid_in_static_rules() {
+ const ruleset1_with_tabId_condition = [
+ getDNRRule({ id: 1, condition: { tabIds: [1] } }),
+ getDNRRule({ id: 3, condition: { urlFilter: "valid-ruleset1-rule" } }),
+ ];
+
+ const ruleset2_with_excludeTabId_condition = [
+ getDNRRule({ id: 2, condition: { excludedTabIds: [1] } }),
+ getDNRRule({ id: 3, condition: { urlFilter: "valid-ruleset2-rule" } }),
+ ];
+
+ const rule_resources = [
+ {
+ id: "ruleset1_with_tabId_condition",
+ enabled: true,
+ path: "ruleset1.json",
+ },
+ {
+ id: "ruleset2_with_excludeTabId_condition",
+ enabled: true,
+ path: "ruleset2.json",
+ },
+ ];
+
+ const files = {
+ "ruleset1.json": JSON.stringify(ruleset1_with_tabId_condition),
+ "ruleset2.json": JSON.stringify(ruleset2_with_excludeTabId_condition),
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(
+ getDNRExtension({
+ id: "tabId-invalid-in-session-rules@mochitest",
+ rule_resources,
+ files,
+ })
+ );
+
+ const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ await extension.awaitMessage("bgpage:ready");
+ await assertDNRGetEnabledRulesets(extension, [
+ "ruleset1_with_tabId_condition",
+ "ruleset2_with_excludeTabId_condition",
+ ]);
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ {
+ message:
+ /"ruleset1_with_tabId_condition": tabIds and excludedTabIds can only be specified in session rules/,
+ },
+ {
+ message:
+ /"ruleset2_with_excludeTabId_condition": tabIds and excludedTabIds can only be specified in session rules/,
+ },
+ ],
+ });
+
+ info("Expect the invalid rule to not be enabled");
+ const dnrStore = ExtensionDNRStore._getStoreForTesting();
+ // Expect the two valid rules to have been loaded as expected.
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset1_with_tabId_condition: getSchemaNormalizedRules(extension, [
+ ruleset1_with_tabId_condition[1],
+ ]),
+ ruleset2_with_excludeTabId_condition: getSchemaNormalizedRules(extension, [
+ ruleset2_with_excludeTabId_condition[1],
+ ]),
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_dnr_all_rules_disabled_allowed() {
+ const ruleset1 = [
+ getDNRRule({ id: 3, condition: { urlFilter: "valid-ruleset1-rule" } }),
+ ];
+
+ const rule_resources = [
+ {
+ id: "ruleset1",
+ enabled: true,
+ path: "ruleset1.json",
+ },
+ ];
+
+ const files = {
+ "ruleset1.json": JSON.stringify(ruleset1),
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(
+ getDNRExtension({
+ id: "all-static-rulesets-disabled-allowed@mochitest",
+ rule_resources,
+ files,
+ })
+ );
+
+ await extension.startup();
+ await extension.awaitMessage("bgpage:ready");
+
+ await assertDNRGetEnabledRulesets(extension, ["ruleset1"]);
+
+ const dnrStore = ExtensionDNRStore._getStoreForTesting();
+ await assertDNRStoreData(dnrStore, extension, {
+ ruleset1: getSchemaNormalizedRules(extension, ruleset1),
+ });
+
+ info("Disable static ruleset1");
+ extension.sendMessage("updateEnabledRulesets", {
+ disableRulesetIds: ["ruleset1"],
+ });
+ await extension.awaitMessage("updateEnabledRulesets:done");
+ await assertDNRGetEnabledRulesets(extension, []);
+ await assertDNRStoreData(dnrStore, extension, {});
+
+ info("Verify that static ruleset1 is still disable after browser restart");
+
+ // NOTE: promiseRestartManager will not be enough to make sure the
+ // DNR store data for the test extension is going to be loaded from
+ // the DNR startup cache file.
+ // See test_ext_dnr_startup_cache.js for a test case that more completely
+ // simulates ExtensionDNRStore initialization on browser restart.
+ await AddonTestUtils.promiseRestartManager();
+
+ await extension.awaitStartup;
+ await ExtensionDNR.ensureInitialized(extension.extension);
+ await extension.awaitMessage("bgpage:ready");
+
+ await assertDNRGetEnabledRulesets(extension, []);
+ await assertDNRStoreData(dnrStore, extension, {});
+
+ await extension.unload();
+});
+
+add_task(async function test_static_rules_telemetry() {
+ resetTelemetryData();
+
+ const ruleset1 = [
+ getDNRRule({
+ id: 1,
+ action: { type: "block" },
+ condition: {
+ resourceTypes: ["xmlhttprequest"],
+ requestDomains: ["example.com"],
+ },
+ }),
+ ];
+ const ruleset2 = [
+ getDNRRule({
+ id: 1,
+ action: { type: "block" },
+ condition: {
+ resourceTypes: ["xmlhttprequest"],
+ requestDomains: ["example.org"],
+ },
+ }),
+ getDNRRule({
+ id: 2,
+ action: { type: "block" },
+ condition: {
+ resourceTypes: ["xmlhttprequest"],
+ requestDomains: ["example2.org"],
+ },
+ }),
+ ];
+
+ const rule_resources = [
+ {
+ id: "ruleset1",
+ enabled: false,
+ path: "ruleset1.json",
+ },
+ {
+ id: "ruleset2",
+ enabled: false,
+ path: "ruleset2.json",
+ },
+ ];
+
+ const files = {
+ "ruleset1.json": JSON.stringify(ruleset1),
+ "ruleset2.json": JSON.stringify(ruleset2),
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(
+ getDNRExtension({
+ id: "tabId-invalid-in-session-rules@mochitest",
+ rule_resources,
+ files,
+ })
+ );
+
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ },
+ {
+ metric: "evaluateRulesTime",
+ mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS",
+ mirroredType: "histogram",
+ },
+ ],
+ "before test extension have been loaded"
+ );
+
+ await extension.startup();
+ await extension.awaitMessage("bgpage:ready");
+
+ await assertDNRGetEnabledRulesets(extension, []);
+
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ },
+ ],
+ "after test extension loaded with all static rulesets disabled"
+ );
+
+ info("Enable static ruleset1");
+ extension.sendMessage("updateEnabledRulesets", {
+ enableRulesetIds: ["ruleset1"],
+ });
+ await extension.awaitMessage("updateEnabledRulesets:done");
+
+ await assertDNRGetEnabledRulesets(extension, ["ruleset1"]);
+
+ // Expect one sample after enabling ruleset1.
+ let expectedValidateRulesTimeSamples = 1;
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: expectedValidateRulesTimeSamples,
+ },
+ ],
+ "after enabling static rulesets1"
+ );
+
+ info("Enable static ruleset2");
+ extension.sendMessage("updateEnabledRulesets", {
+ enableRulesetIds: ["ruleset2"],
+ });
+ await extension.awaitMessage("updateEnabledRulesets:done");
+
+ await assertDNRGetEnabledRulesets(extension, ["ruleset1", "ruleset2"]);
+
+ // Expect one new sample after enabling ruleset2.
+ expectedValidateRulesTimeSamples += 1;
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: expectedValidateRulesTimeSamples,
+ },
+ ],
+ "after enabling static rulesets2"
+ );
+
+ await extension.addon.disable();
+
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: expectedValidateRulesTimeSamples,
+ },
+ ],
+ "no new samples expected after disabling test extension"
+ );
+
+ await extension.addon.enable();
+ await extension.awaitMessage("bgpage:ready");
+ await ExtensionDNR.ensureInitialized(extension.extension);
+
+ // Expect 2 new samples after re-enabling the addon with
+ // the 2 rulesets enabled being loaded from the DNR store file.
+ expectedValidateRulesTimeSamples += 2;
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: expectedValidateRulesTimeSamples,
+ },
+ ],
+ "after re-enabling test extension"
+ );
+
+ info("Disable static ruleset1");
+
+ extension.sendMessage("updateEnabledRulesets", {
+ disableRulesetIds: ["ruleset1"],
+ });
+ await extension.awaitMessage("updateEnabledRulesets:done");
+
+ await assertDNRGetEnabledRulesets(extension, ["ruleset2"]);
+
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "validateRulesTime",
+ mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: expectedValidateRulesTimeSamples,
+ },
+ ],
+ "no new validation should be hit after disabling ruleset1"
+ );
+
+ info("Verify telemetry recorded on rules evaluation");
+ extension.sendMessage("updateEnabledRulesets", {
+ enableRulesetIds: ["ruleset1"],
+ disableRulesetIds: ["ruleset2"],
+ });
+ await extension.awaitMessage("updateEnabledRulesets:done");
+ await assertDNRGetEnabledRulesets(extension, ["ruleset1"]);
+
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "evaluateRulesTime",
+ mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS",
+ mirroredType: "histogram",
+ },
+ {
+ metric: "evaluateRulesCountMax",
+ mirroredName: "extensions.apis.dnr.evaluate_rules_count_max",
+ mirroredType: "scalar",
+ },
+ ],
+ "before any request have been intercepted"
+ );
+
+ Assert.equal(
+ await fetch("http://example.com/").then(res => res.text()),
+ "response from server",
+ "DNR should not block system requests"
+ );
+
+ assertDNRTelemetryMetricsNoSamples(
+ [
+ {
+ metric: "evaluateRulesTime",
+ mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS",
+ mirroredType: "histogram",
+ },
+ {
+ metric: "evaluateRulesCountMax",
+ mirroredName: "extensions.apis.dnr.evaluate_rules_count_max",
+ mirroredType: "scalar",
+ },
+ ],
+ "after restricted request have been intercepted (but no rules evaluated)"
+ );
+
+ const page = await ExtensionTestUtils.loadContentPage("http://example.com");
+ const callPageFetch = async () => {
+ Assert.equal(
+ await page.spawn([], () => {
+ return this.content.fetch("http://example.com/").then(
+ res => res.text(),
+ err => err.message
+ );
+ }),
+ "NetworkError when attempting to fetch resource.",
+ "DNR should have blocked test request to example.com"
+ );
+ };
+
+ // Expect one sample recorded on evaluating rules for the
+ // top level navigation.
+ let expectedEvaluateRulesTimeSamples = 1;
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "evaluateRulesTime",
+ mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: expectedEvaluateRulesTimeSamples,
+ },
+ ],
+ "evaluateRulesTime should be collected after evaluated rulesets"
+ );
+ // Expect same number of rules included in the single ruleset
+ // currently enabled.
+ let expectedEvaluateRulesCountMax = ruleset1.length;
+ assertDNRTelemetryMetricsGetValueEq(
+ [
+ {
+ metric: "evaluateRulesCountMax",
+ mirroredName: "extensions.apis.dnr.evaluate_rules_count_max",
+ mirroredType: "scalar",
+ expectedGetValue: expectedEvaluateRulesCountMax,
+ },
+ ],
+ "evaluateRulesCountMax should be collected after evaluated rulesets1"
+ );
+
+ await callPageFetch();
+
+ // Expect one new sample reported on evaluating rules for the
+ // first fetch request originated from the test page.
+ expectedEvaluateRulesTimeSamples += 1;
+ assertDNRTelemetryMetricsSamplesCount(
+ [
+ {
+ metric: "evaluateRulesTime",
+ mirroredName: "WEBEXT_DNR_EVALUATE_RULES_MS",
+ mirroredType: "histogram",
+ expectedSamplesCount: expectedEvaluateRulesTimeSamples,
+ },
+ ],
+ "evaluateRulesTime should be collected after evaluated rulesets"
+ );
+
+ extension.sendMessage("updateEnabledRulesets", {
+ enableRulesetIds: ["ruleset2"],
+ });
+ await extension.awaitMessage("updateEnabledRulesets:done");
+ await assertDNRGetEnabledRulesets(extension, ["ruleset1", "ruleset2"]);
+
+ await callPageFetch();
+
+ // Expect 3 rules with both rulesets enabled
+ // (1 from ruleset1 and 2 more from ruleset2).
+ expectedEvaluateRulesCountMax += ruleset2.length;
+ assertDNRTelemetryMetricsGetValueEq(
+ [
+ {
+ metric: "evaluateRulesCountMax",
+ mirroredName: "extensions.apis.dnr.evaluate_rules_count_max",
+ mirroredType: "scalar",
+ expectedGetValue: expectedEvaluateRulesCountMax,
+ },
+ ],
+ "evaluateRulesCountMax should have been increased after enabling ruleset2"
+ );
+
+ extension.sendMessage("updateEnabledRulesets", {
+ disableRulesetIds: ["ruleset2"],
+ });
+ await extension.awaitMessage("updateEnabledRulesets:done");
+ await assertDNRGetEnabledRulesets(extension, ["ruleset1"]);
+
+ await callPageFetch();
+
+ assertDNRTelemetryMetricsGetValueEq(
+ [
+ {
+ metric: "evaluateRulesCountMax",
+ mirroredName: "extensions.apis.dnr.evaluate_rules_count_max",
+ mirroredType: "scalar",
+ expectedGetValue: expectedEvaluateRulesCountMax,
+ },
+ ],
+ "evaluateRulesCountMax should have not been decreased after disabling ruleset2"
+ );
+
+ await page.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_system_restrictions.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_system_restrictions.js
new file mode 100644
index 0000000000..84464d0cba
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_system_restrictions.js
@@ -0,0 +1,283 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com", "restricted"] });
+server.registerPathHandler("/", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.write("response from server");
+});
+server.registerPathHandler("/style_with_import.css", (req, res) => {
+ res.setHeader("Content-Type", "text/css");
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.write("@import url('http://example.com/imported.css');");
+});
+server.registerPathHandler("/imported.css", (req, res) => {
+ res.setHeader("Content-Type", "text/css");
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.write("imported_stylesheet_here { }");
+});
+
+add_setup(() => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+ // The restrictedDomains pref should be set early, because the pref is read
+ // only once (on first use) by WebExtensionPolicy::IsRestrictedURI.
+ Services.prefs.setCharPref(
+ "extensions.webextensions.restrictedDomains",
+ "restricted"
+ );
+});
+
+async function startDNRExtension() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { resourceTypes: ["xmlhttprequest", "stylesheet"] },
+ action: { type: "block" },
+ },
+ {
+ id: 2,
+ condition: { urlFilter: "blockme", resourceTypes: ["main_frame"] },
+ action: { type: "block" },
+ },
+ ],
+ });
+ browser.test.sendMessage("dnr_registered");
+ },
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("dnr_registered");
+ return extension;
+}
+
+add_task(async function dnr_ignores_system_requests() {
+ let extension = await startDNRExtension();
+ Assert.equal(
+ await (await fetch("http://example.com/")).text(),
+ "response from server",
+ "DNR should not block requests from system principal"
+ );
+ await extension.unload();
+});
+
+add_task(async function dnr_ignores_requests_to_restrictedDomains() {
+ let extension = await startDNRExtension();
+ Assert.equal(
+ await ExtensionTestUtils.fetch("http://example.com/", "http://restricted/"),
+ "response from server",
+ "DNR should not block destination in restrictedDomains"
+ );
+ await extension.unload();
+});
+
+add_task(async function dnr_ignores_initiator_from_restrictedDomains() {
+ let extension = await startDNRExtension();
+ Assert.equal(
+ await ExtensionTestUtils.fetch("http://restricted/", "http://example.com/"),
+ "response from server",
+ "DNR should not block requests initiated from a page in restrictedDomains"
+ );
+ await extension.unload();
+});
+
+add_task(async function dnr_ignores_navigation_to_restrictedDomains() {
+ let extension = await startDNRExtension();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://restricted/?blockme"
+ );
+ await contentPage.spawn([], () => {
+ const { document } = content;
+ Assert.equal(document.URL, "http://restricted/?blockme", "Same URL");
+ Assert.equal(document.body.textContent, "response from server", "body");
+ });
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function dnr_ignores_css_import_at_restrictedDomains() {
+ // CSS @import have triggeringPrincipal set to the URL of the stylesheet,
+ // and the loadingPrincipal set to the web page. To verify that access is
+ // indeed being restricted as expected, confirm that none of the stylesheet
+ // requests are blocked by the DNR extension.
+ let extension = await startDNRExtension();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://restricted/"
+ );
+ await contentPage.spawn([], async () => {
+ // Use wrappedJSObject so that all operations below are with the principal
+ // of the content instead of the system principal (from this ContentTask).
+ const { document } = content.wrappedJSObject;
+ const style = document.createElement("link");
+ style.rel = "stylesheet";
+ // Note: intentionally not at "http://restricted/" because we want to check
+ // that subresources from a restricted domain are ignored by DNR..
+ style.href = "http://example.com/style_with_import.css";
+ style.crossOrigin = "anonymous";
+ await new Promise(resolve => {
+ info("Waiting for style sheet to load...");
+ style.onload = resolve;
+ document.head.append(style);
+ });
+ const importRule = style.sheet.cssRules[0];
+ Assert.equal(
+ importRule?.cssText,
+ `@import url("http://example.com/imported.css");`,
+ "Not blocked by DNR: Loaded style_with_import.css"
+ );
+ // Waiving Xrays here because we cannot read cssRules despite CORS because
+ // that is not implemented for child stylesheets (loaded via @import):
+ // https://searchfox.org/mozilla-central/rev/55d5c4b9dffe5e59eb6b019c1a930ec9ada47e10/layout/style/Loader.cpp#2052
+ const importedStylesheet = Cu.unwaiveXrays(importRule.styleSheet);
+ Assert.equal(
+ importedStylesheet.cssRules[0]?.cssText,
+ "imported_stylesheet_here { }",
+ "Not blocked by DNR: Loaded import.css"
+ );
+ });
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(
+ { pref_set: [["extensions.dnr.feedback", true]] },
+ async function testMatchOutcome_and_restrictedDomains() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [{ id: 1, condition: {}, action: { type: "block" } }],
+ });
+ const type = "other"; // matches the condition of the above rule.
+
+ browser.test.assertDeepEq(
+ { matchedRules: [] },
+ await browser.declarativeNetRequest.testMatchOutcome({
+ url: "http://restricted/",
+ type,
+ }),
+ "testMatchOutcome ignores restricted url"
+ );
+ browser.test.assertDeepEq(
+ { matchedRules: [] },
+ await browser.declarativeNetRequest.testMatchOutcome({
+ url: "http://example.com/",
+ initiator: "http://restricted/",
+ type,
+ }),
+ "testMatchOutcome ignores restricted initiator"
+ );
+ browser.test.sendMessage("done");
+ },
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ }
+);
+
+add_task(
+ // In debug builds, any attempt to load data:-URLs in the parent process
+ // results in a crash or at least a logged error, via
+ // nsContentSecurityUtils::ValidateScriptFilename.
+ //
+ // Xpcshell tests use loadFrameScript with data:-URLs, which could trigger the
+ // above error / crash, when a page is loaded in the parent process.
+ // For example, the following error message (or crash),
+ // "InternalError: unsafe filename: data:text/javascript,//"
+ // "Hit MOZ_CRASH(Blocking a script load data:text/javascript,// from file (None))"
+ // is triggered because of the loadFrameScript call at
+ // https://searchfox.org/mozilla-central/rev/11dbac7f64f509b78037465cbb4427ed71f8b565/testing/modules/XPCShellContentUtils.sys.mjs#308
+ //
+ // This test loads about:logo in the parent, because nsAboutRedirector.cpp
+ // registers about:logo without nsIAboutModule::URI_MUST_LOAD_IN_CHILD.
+ // When about:logo is loaded, the ContentPage test helper also triggers the
+ // above error/crash at:
+ // https://searchfox.org/mozilla-central/rev/11dbac7f64f509b78037465cbb4427ed71f8b565/testing/modules/XPCShellContentUtils.sys.mjs#224,242
+ //
+ // Opt out of the check/crash from ValidateScriptFilename:
+ { pref_set: [["security.allow_parent_unrestricted_js_loads", true]] },
+ async function non_system_request_with_disallowed_scheme() {
+ let extension = await startDNRExtension();
+ Assert.equal(
+ await (await fetch("http://example.com/")).text(),
+ "response from server",
+ "DNR should not block requests from system principal"
+ );
+ // We are loading about:logo for the following reasons:
+ // - It is a regular content principal, NOT a system principal.
+ // - It is an about:-URL that resolves across all builds (part of toolkit/).
+ // - It does not have a CSP (intentional - bug 1587417). That enables us to
+ // send a fetch() request below.
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "about:logo?blockme"
+ );
+ await contentPage.spawn([], async () => {
+ const { document } = content;
+ // To make sure that the test does not pass trivially, we verify that it
+ // is not the system principal (because dnr_ignores_system_requests
+ // already tests that) and not a null principal (because that translates
+ // to a void "initiator" in the DNR API, which would pass access checks).
+ Assert.ok(
+ document.nodePrincipal.isContentPrincipal,
+ "about:logo has content principal (not system or NullPrincipal))"
+ );
+ Assert.equal(document.URL, "about:logo?blockme", "Same URL");
+ Assert.equal(
+ await (await content.fetch("http://example.com/")).text(),
+ "response from server",
+ "fetch() at about:logo not blocked by DNR"
+ );
+ });
+ await contentPage.close();
+ await extension.unload();
+ }
+);
+
+add_task(
+ { pref_set: [["extensions.dnr.feedback", true]] },
+ async function testMatchOutcome_non_system_request_with_disallowed_scheme() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [{ id: 1, condition: {}, action: { type: "block" } }],
+ });
+ const type = "other"; // matches the condition of the above rule.
+
+ browser.test.assertDeepEq(
+ { matchedRules: [] },
+ await browser.declarativeNetRequest.testMatchOutcome({
+ url: "about:logo",
+ type,
+ }),
+ "testMatchOutcome ignores url with disallowed schema"
+ );
+ browser.test.assertDeepEq(
+ { matchedRules: [] },
+ await browser.declarativeNetRequest.testMatchOutcome({
+ url: "http://example.com/",
+ initiator: "about:logo",
+ type,
+ }),
+ "testMatchOutcome ignores initiator with disallowed schema"
+ );
+ browser.test.sendMessage("done");
+ },
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_tabIds.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_tabIds.js
new file mode 100644
index 0000000000..84b75bb5be
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_tabIds.js
@@ -0,0 +1,249 @@
+"use strict";
+
+// This test verifies that the internals for associating requests with tabId
+// are only active when a session rule with a tabId rule exists.
+//
+// There are tests for the logic of tabId matching in the match_tabIds task in
+// toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js
+//
+// And there are tests that verify matching with real network requests in
+// toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html
+
+const server = createHttpServer({ hosts: ["from", "any", "in", "ex"] });
+server.registerPathHandler("/", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+});
+
+let gTabLookupSpy;
+
+add_setup(async () => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+
+ // Install a spy on WebRequest.getTabIdForChannelWrapper.
+ const { WebRequest } = ChromeUtils.importESModule(
+ "resource://gre/modules/WebRequest.sys.mjs"
+ );
+ const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+ );
+ gTabLookupSpy = sinon.spy(WebRequest, "getTabIdForChannelWrapper");
+
+ await ExtensionTestUtils.startAddonManager();
+});
+
+function numberOfTabLookupsSinceLastCheck() {
+ let result = gTabLookupSpy.callCount;
+ gTabLookupSpy.resetHistory();
+ return result;
+}
+
+// This test checks that WebRequest.getTabIdForChannelWrapper is only called
+// when there are any registered tabId/excludedTabIds rules. Moreover, it
+// verifies that after unloading (reloading) the extension, that the method is
+// still not called unnecessarily.
+add_task(async function getTabIdForChannelWrapper_only_called_when_needed() {
+ async function background() {
+ const RULE_ANY_TAB_ID = {
+ id: 1,
+ condition: { requestDomains: ["from"] },
+ action: { type: "redirect", redirect: { url: "http://any/" } },
+ };
+ const RULE_INCLUDE_TAB_ID = {
+ id: 2,
+ condition: { requestDomains: ["from"], tabIds: [-1] },
+ action: { type: "redirect", redirect: { url: "http://in/" } },
+ priority: 2,
+ };
+ const RULE_EXCLUDE_TAB_ID = {
+ id: 3,
+ condition: { requestDomains: ["from"], excludedTabIds: [-1] },
+ action: { type: "redirect", redirect: { url: "http://ex/" } },
+ priority: 2,
+ };
+ async function promiseOneMessage(messageName) {
+ return new Promise(resolve => {
+ browser.test.onMessage.addListener(function listener(msg, result) {
+ if (messageName === msg) {
+ browser.test.onMessage.removeListener(listener);
+ resolve(result);
+ }
+ });
+ });
+ }
+ async function numberOfTabLookupsSinceLastCheck() {
+ let promise = promiseOneMessage("tabLookups");
+ browser.test.sendMessage("getTabLookups");
+ return promise;
+ }
+ async function testFetchUrl(url, expectedUrl, expectedCount, description) {
+ let res = await fetch(url);
+ browser.test.assertEq(expectedUrl, res.url, `Final URL for ${url}`);
+ browser.test.assertEq(
+ expectedCount,
+ await numberOfTabLookupsSinceLastCheck(),
+ `Expected number of tab lookups - ${url} - ${description}`
+ );
+ }
+
+ const startupCountPromise = promiseOneMessage("extensionStartupCount");
+ browser.test.sendMessage("extensionStarted");
+ const startupCount = await startupCountPromise;
+ if (startupCount !== 0) {
+ browser.test.assertEq(1, startupCount, "Extension restarted once");
+
+ // Note: declarativeNetRequest.updateSessionRules is intentionally not
+ // called here, because we want to verify that upon unloading the
+ // extension, that the tabId lookup logic was properly cleaned up,
+ // i.e. that NetworkIntegration.maybeUpdateTabIdChecker() was called.
+
+ await testFetchUrl(
+ "http://from/?after-restart-supposedly-no-include-tab",
+ "http://from/?after-restart-supposedly-no-include-tab",
+ 0,
+ "No lookup because session rules should have disappeared at reload"
+ );
+
+ browser.test.assertDeepEq(
+ [],
+ await browser.declarativeNetRequest.getSessionRules(),
+ "The session rules have indeed been cleared upon reload."
+ );
+
+ browser.test.sendMessage("test_completed_after_reload");
+ return;
+ }
+
+ browser.test.assertEq(
+ 0,
+ await numberOfTabLookupsSinceLastCheck(),
+ "Initially, no tab lookups"
+ );
+
+ await testFetchUrl(
+ "http://from/?no_dnr_rules",
+ "http://from/?no_dnr_rules",
+ 0,
+ "No tab lookups without any registered DNR rules"
+ );
+
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [RULE_ANY_TAB_ID],
+ });
+ // Active rules now: RULE_ANY_TAB_ID
+
+ await testFetchUrl(
+ "http://from/?only_dnr_rule_matches_any_tab",
+ "http://any/",
+ 0,
+ "No tab lookups when only rule has no tabIds/excludedTabIds conditions"
+ );
+
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [RULE_EXCLUDE_TAB_ID],
+ });
+ // Active rules now: RULE_ANY_TAB_ID, RULE_EXCLUDE_TAB_ID
+
+ await testFetchUrl(
+ "http://from/?dnr_rule_matches_any,dnr_rule_excludes_-1",
+ // should be "any" instead of "ex" because excludedTabIds: [-1] should
+ // exclude the background.
+ "http://any/",
+ 2, // initial request + redirect request.
+ "Expected tabId lookup when a tabId rule is registered"
+ );
+
+ await browser.declarativeNetRequest.updateSessionRules({
+ removeRuleIds: [RULE_ANY_TAB_ID.id],
+ });
+ // Active rules now: RULE_EXCLUDE_TAB_ID
+
+ await testFetchUrl(
+ "http://from/?only_dnr_rule_excludes_-1",
+ // Not redirected to "ex" because excludedTabIds: [-1] does not match the
+ // background that has tabId -1.
+ "http://from/?only_dnr_rule_excludes_-1",
+ 1,
+ "Expected lookup after unregistering unrelated rule, keeping tabId rule"
+ );
+
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [RULE_INCLUDE_TAB_ID],
+ });
+ // Active rules now: RULE_EXCLUDE_TAB_ID, RULE_INCLUDE_TAB_ID
+ await testFetchUrl(
+ "http://from/?two_dnr_rule_include_and_exclude_-1",
+ "http://in/",
+ 2, // initial request + redirect request.
+ "Expecting lookup because of 2 DNR rules with tabId and excludedTabIds"
+ );
+
+ await browser.declarativeNetRequest.updateSessionRules({
+ removeRuleIds: [RULE_EXCLUDE_TAB_ID.id],
+ });
+ // Active rules now: RULE_INCLUDE_TAB_ID
+
+ await testFetchUrl(
+ "http://from/?only_dnr_rule_includes_-1",
+ "http://in/",
+ 2, // initial request + redirect request.
+ "Expecting lookup because of remaining tabId DNR rule"
+ );
+
+ await browser.declarativeNetRequest.updateSessionRules({
+ removeRuleIds: [RULE_INCLUDE_TAB_ID.id],
+ });
+ // Active rules now: none
+
+ await testFetchUrl(
+ "http://from/?no_rules_again",
+ "http://from/?no_rules_again",
+ 0,
+ "Expected no lookups after unregistering the last remaining rule"
+ );
+
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [RULE_INCLUDE_TAB_ID],
+ });
+ // Active rules now: RULE_INCLUDE_TAB_ID
+
+ await testFetchUrl(
+ "http://from/?again_with-include-1",
+ "http://in/",
+ 2, // initial request + redirect request.
+ "Expecting lookup again because of include rule"
+ );
+
+ // Ending test with remaining rule: RULE_INCLUDE_TAB_ID
+ // Reload extension.
+ browser.test.sendMessage("reload_extension");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ useAddonManager: "temporary", // for reload and granted_host_permissions.
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ host_permissions: ["*://from/*"],
+ granted_host_permissions: true,
+ permissions: ["declarativeNetRequest"],
+ },
+ });
+ extension.onMessage("getTabLookups", () => {
+ extension.sendMessage("tabLookups", numberOfTabLookupsSinceLastCheck());
+ });
+ let startupCount = 0;
+ extension.onMessage("extensionStarted", () => {
+ extension.sendMessage("extensionStartupCount", startupCount++);
+ });
+ await extension.startup();
+ await extension.awaitMessage("reload_extension");
+ await extension.addon.reload();
+ await extension.awaitMessage("test_completed_after_reload");
+ Assert.equal(
+ 0,
+ numberOfTabLookupsSinceLastCheck(),
+ "No new tab lookups since completion of extension tests"
+ );
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js
new file mode 100644
index 0000000000..d9cc523d99
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js
@@ -0,0 +1,1504 @@
+"use strict";
+
+add_setup(() => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.feedback", true);
+
+ // Don't turn warnings in errors, to make sure that the parameter validation
+ // tests verify real-world behavior, instead of the stricter test-only mode.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+});
+
+// This function is serialized and called in the context of the test extension's
+// background page. dnrTestUtils is passed to the background function.
+function makeDnrTestUtils() {
+ const dnrTestUtils = {};
+ const dnr = browser.declarativeNetRequest;
+ function makeDummyAction(type) {
+ switch (type) {
+ case "redirect":
+ return { type, redirect: { url: "https://example.com/dummy" } };
+ case "modifyHeaders":
+ return {
+ type,
+ responseHeaders: [{ operation: "append", header: "x", value: "y" }],
+ };
+ default:
+ return { type };
+ }
+ }
+ function makeDummyRequest() {
+ // A value that matches the condition from makeDummyRule().
+ return { url: "https://example.com/some-dummy-url", type: "main_frame" };
+ }
+ function makeDummyRule(id, actionType) {
+ return {
+ id,
+ // condition matches makeDummyRequest().
+ condition: { resourceTypes: ["main_frame"] },
+ action: makeDummyAction(actionType),
+ };
+ }
+ async function testMatchesRequest(request, ruleIds, description) {
+ browser.test.assertDeepEq(
+ ruleIds,
+ (await dnr.testMatchOutcome(request)).matchedRules.map(mr => mr.ruleId),
+ description
+ );
+ }
+ async function testCanMatchAnyBlock({ matchedRequests, nonMatchedRequests }) {
+ await dnr.updateSessionRules({
+ addRules: [
+ {
+ // A rule that is supposed to match everything.
+ id: 1,
+ condition: { excludedResourceTypes: [] },
+ action: { type: "block" },
+ },
+ ],
+ });
+ for (let request of matchedRequests) {
+ await testMatchesRequest(
+ request,
+ [1],
+ `${JSON.stringify(request)} - should match wildcard DNR block rule`
+ );
+ }
+ for (let request of nonMatchedRequests) {
+ await testMatchesRequest(
+ request,
+ [],
+ `${JSON.stringify(request)} - should not match any DNR rule`
+ );
+ }
+ await dnr.updateSessionRules({ removeRuleIds: [1] });
+ }
+ async function testCanUseAction(type, canUse) {
+ await dnr.updateSessionRules({ addRules: [makeDummyRule(1, type)] });
+ await testMatchesRequest(
+ makeDummyRequest(),
+ canUse ? [1] : [],
+ `${type} - should${canUse ? "" : " not"} match`
+ );
+ await dnr.updateSessionRules({ removeRuleIds: [1] });
+ }
+ Object.assign(dnrTestUtils, {
+ makeDummyAction,
+ makeDummyRequest,
+ makeDummyRule,
+ testMatchesRequest,
+ testCanMatchAnyBlock,
+ testCanUseAction,
+ });
+ return dnrTestUtils;
+}
+
+async function runAsDNRExtension({
+ background,
+ manifest,
+ unloadTestAtEnd = true,
+}) {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${background})((${makeDnrTestUtils})())`,
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ host_permissions: ["<all_urls>"],
+ granted_host_permissions: true,
+ ...manifest,
+ },
+ temporarilyInstalled: true, // <-- for granted_host_permissions
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ if (unloadTestAtEnd) {
+ await extension.unload();
+ }
+ return extension;
+}
+
+add_task(async function validate_required_params() {
+ await runAsDNRExtension({
+ background: async () => {
+ const testMatchOutcome = browser.declarativeNetRequest.testMatchOutcome;
+
+ browser.test.assertThrows(
+ () => testMatchOutcome({ type: "image" }),
+ /Type error for parameter request \(Property "url" is required\)/,
+ "url is required"
+ );
+ browser.test.assertThrows(
+ () => testMatchOutcome({ url: "https://example.com/" }),
+ /Type error for parameter request \(Property "type" is required\)/,
+ "resource type is required"
+ );
+
+ browser.test.assertDeepEq(
+ { matchedRules: [] },
+ await testMatchOutcome({ url: "https://example.com/", type: "image" }),
+ "testMatchOutcome with url and type succeeds"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function resource_type_validation() {
+ await runAsDNRExtension({
+ background: async () => {
+ const testMatchOutcome = browser.declarativeNetRequest.testMatchOutcome;
+
+ const url = "https://example.com/some-dummy-url";
+
+ browser.test.assertThrows(
+ () => testMatchOutcome({ url, type: "MAIN_FRAME" }),
+ /Error processing type: Invalid enumeration value "MAIN_FRAME"/,
+ "testMatchOutcome should expects a lowercase type"
+ );
+
+ // Check that at least one ResourceType exists.
+ browser.test.assertEq(
+ "main_frame",
+ browser.declarativeNetRequest.ResourceType.MAIN_FRAME,
+ "ResourceType.MAIN_FRAME exists"
+ );
+
+ for (let type of Object.values(
+ browser.declarativeNetRequest.ResourceType
+ )) {
+ browser.test.assertDeepEq(
+ { matchedRules: [] },
+ await testMatchOutcome({ url, type }),
+ `testMatchOutcome for type=${type} is allowed`
+ );
+ }
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function url_validation() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const dnr = browser.declarativeNetRequest;
+ const { testMatchesRequest } = dnrTestUtils;
+
+ const type = "other"; // Dummy resource type.
+ await dnr.updateSessionRules({
+ addRules: [{ id: 1, condition: {}, action: { type: "block" } }],
+ });
+
+ const supportedUrls = [
+ // All schemes that are potentially hooked up to the network are here.
+ "http://example.com/",
+ "https://example.com/",
+ // While host permissions permits more (e.g. file:, moz-extension:),
+ // we don't list them here since they are not hooked up to the network.
+ // Trying to match such URLs is undefined behavior for now.
+ ];
+ const supportedInitiators = [
+ // Supported URLs are also supported initiators.
+ ...supportedUrls,
+ // Note: moz-extension: has more tests in match_initiator_moz_extension.
+ `moz-extension://${location.host}`,
+ "file:///tmp/",
+ // data:-URIs have a null principal.
+ "data:text/plain,",
+ ];
+ const disallowedUrlsOrInitiators = [
+ // about:-URI with system principal:
+ "about:config",
+ // Unprivileged about:-URL:
+ "about:logo",
+ "chrome://extensions/content/dummy.xhtml",
+ "resource://pdf.js/web/viewer.html",
+ // Extensions cannot see "view-source", only the result: bug 1683646.
+ "view-source:http://example.com/",
+ "view-source:about:config",
+ // blob:-URLs do not go through the network. An actual network request
+ // will never have a blob-URI as initiator, always the actual principal
+ // URI. We don't try to extract the actual principal from the blob:-URI
+ // because that is expensive and also performs a validation that the
+ // blob:-URI is still valid, so testMatchOutcome could then return
+ // inconsistent results.
+ URL.createObjectURL(new Blob([])),
+ ];
+ const disallowedUrls = [
+ ...disallowedUrlsOrInitiators,
+ // data:-URIs are not hooked up to the network (bug 1631933), so we do
+ // not support it in the testMatchOutcome API, even though the URL
+ // matches "<all_urls>".
+ "data:text/plain,",
+ ];
+ const disallowedInitiator = [
+ ...disallowedUrlsOrInitiators,
+ // "about:blank" inherits the principal or is null. testMatchOutcome
+ // does not offer a way to specify it more precisely.
+ "about:blank",
+ // This is bogus: A principal URL can never be about:srcdoc. It is
+ // always inherit from something.
+ "about:srcdoc",
+ "moz-extension://someone-elses-extension-here",
+ ];
+
+ for (let url of supportedUrls) {
+ await testMatchesRequest({ url, type }, [1], `Supported url: ${url}`);
+ }
+ for (let initiator of supportedInitiators) {
+ await testMatchesRequest(
+ { url: "http://example.com/", type, initiator },
+ [1],
+ `Supported initiator: ${initiator}`
+ );
+ }
+ for (let url of disallowedUrls) {
+ await testMatchesRequest({ type, url }, [], `Disallowed url: ${url}`);
+ }
+ for (let initiator of disallowedInitiator) {
+ await testMatchesRequest(
+ { url: "http://example.com/", type, initiator },
+ [],
+ `Disallowed initiator: ${initiator}`
+ );
+ }
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function rule_priority_and_action_type_precedence() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const dnr = browser.declarativeNetRequest;
+ const { makeDummyRule, makeDummyRequest } = dnrTestUtils;
+
+ await dnr.updateSessionRules({
+ addRules: [
+ makeDummyRule(1, "allow"),
+ makeDummyRule(2, "allowAllRequests"),
+ makeDummyRule(3, "block"),
+ makeDummyRule(4, "upgradeScheme"),
+ makeDummyRule(5, "redirect"),
+ makeDummyRule(6, "modifyHeaders"),
+ { ...makeDummyRule(7, "modifyHeaders"), priority: 2 },
+ { ...makeDummyRule(8, "allow"), priority: 2 },
+ { ...makeDummyRule(9, "block"), priority: 2 },
+ // Repeat rules so that we can verify that the outcome is due to the
+ // rule action, instead of the rule ID / input order.
+ makeDummyRule(11, "allow"),
+ makeDummyRule(12, "allowAllRequests"),
+ makeDummyRule(13, "block"),
+ makeDummyRule(14, "upgradeScheme"),
+ makeDummyRule(15, "redirect"),
+ makeDummyRule(16, "modifyHeaders"),
+ { ...makeDummyRule(17, "modifyHeaders"), priority: 2 },
+ ],
+ });
+ async function testAndRemove(ruleId, expectedRuleIds, description) {
+ browser.test.assertDeepEq(
+ expectedRuleIds.map(ruleId => ({ ruleId, rulesetId: "_session" })),
+ (await dnr.testMatchOutcome(makeDummyRequest())).matchedRules,
+ description
+ );
+ await dnr.updateSessionRules({ removeRuleIds: [ruleId] });
+ }
+
+ await testAndRemove(8, [8], "highest-prio allow wins");
+ await testAndRemove(9, [9], "highest-prio block wins");
+ // after this point, we only have same-prio rules and two higher-prio
+ // modifyHeaders rules (7 & 17).
+
+ await testAndRemove(
+ 1,
+ [1, 7, 17],
+ "1st allow ignores other rules, except for higher-prio modifyHeaders"
+ );
+ await testAndRemove(
+ 11,
+ [11, 7, 17],
+ "2nd allow ignores other rules, except for higher-prio modifyHeaders"
+ );
+
+ await testAndRemove(
+ 2,
+ [2, 7, 17],
+ "1st allowAllRequests ignores other rules, except for higher-prio modifyHeaders"
+ );
+ await testAndRemove(
+ 12,
+ [12, 7, 17],
+ "2nd allowAllRequests ignores other rules, except for higher-prio modifyHeaders"
+ );
+
+ await testAndRemove(3, [3], "1st block > all other actions");
+ await testAndRemove(13, [13], "2nd block > all other actions");
+
+ await testAndRemove(4, [4], "1st upgradeScheme > redirect");
+ await testAndRemove(14, [14], "2nd upgradeScheme > redirect");
+
+ await testAndRemove(5, [5], "1st redirect > modifyHeaders");
+ await testAndRemove(15, [15], "2nd redirect > modifyHeaders");
+
+ await testAndRemove(
+ 6,
+ [7, 17, 6, 16],
+ "All modifyHeaders match if there is no other action"
+ );
+
+ // Verify that a new rule takes precedence again.
+ await dnr.updateSessionRules({
+ addRules: [makeDummyRule(11, "allow")],
+ });
+ await testAndRemove(
+ 11,
+ [11, 7, 17],
+ "After adding an allow rule, only higher-prio modifyHeaders are shown"
+ );
+
+ browser.test.assertDeepEq(
+ [7, 16, 17],
+ (await dnr.getSessionRules()).map(r => r.id),
+ "Remaining rules at end of test"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function declarativeNetRequest_and_host_permissions() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testCanUseAction, testCanMatchAnyBlock } = dnrTestUtils;
+
+ // Unlocked by declarativeNetRequest permission:
+ await testCanUseAction("allow", true);
+ await testCanUseAction("allowAllRequests", true);
+ await testCanUseAction("block", true);
+ await testCanUseAction("upgradeScheme", true);
+ // Unlocked by host permissions:
+ await testCanUseAction("redirect", true);
+ await testCanUseAction("modifyHeaders", true);
+
+ const url = "https://example.com/";
+ await testCanMatchAnyBlock({
+ matchedRequests: [
+ { url, type: "other" },
+ { url, type: "main_frame" },
+ { url, type: "sub_frame" },
+ { url, initiator: url, type: "other" },
+ { url, initiator: url, type: "main_frame" },
+ { url, initiator: url, type: "sub_frame" },
+ ],
+ nonMatchedRequests: [],
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function declarativeNetRequest_permission_only() {
+ await runAsDNRExtension({
+ manifest: {
+ host_permissions: [],
+ },
+ background: async dnrTestUtils => {
+ const { testCanUseAction, testCanMatchAnyBlock } = dnrTestUtils;
+
+ // Unlocked by declarativeNetRequest permission:
+ await testCanUseAction("allow", true);
+ await testCanUseAction("allowAllRequests", true);
+ await testCanUseAction("block", true);
+ await testCanUseAction("upgradeScheme", true);
+ // These require host permissions, which we don't have:
+ await testCanUseAction("redirect", false);
+ await testCanUseAction("modifyHeaders", false);
+
+ const url = "https://example.com/";
+ await testCanMatchAnyBlock({
+ matchedRequests: [
+ { url, type: "other" },
+ { url, type: "main_frame" },
+ { url, type: "sub_frame" },
+ { url, initiator: url, type: "other" },
+ { url, initiator: url, type: "main_frame" },
+ { url, initiator: url, type: "sub_frame" },
+ ],
+ nonMatchedRequests: [],
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function declarativeNetRequestWithHostAccess_only() {
+ await runAsDNRExtension({
+ manifest: {
+ permissions: [
+ "declarativeNetRequestWithHostAccess",
+ "declarativeNetRequestFeedback",
+ ],
+ host_permissions: [],
+ },
+ background: async dnrTestUtils => {
+ const { testCanUseAction } = dnrTestUtils;
+
+ // declarativeNetRequestWithHostAccess requires host permissions,
+ // which we don't have. So none of the rules should match:
+ await testCanUseAction("allow", false);
+ await testCanUseAction("allowAllRequests", false);
+ await testCanUseAction("block", false);
+ await testCanUseAction("upgradeScheme", false);
+ await testCanUseAction("redirect", false);
+ await testCanUseAction("modifyHeaders", false);
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function declarativeNetRequestWithHostAccess_and_host_perm() {
+ await runAsDNRExtension({
+ manifest: {
+ permissions: [
+ "declarativeNetRequestWithHostAccess",
+ "declarativeNetRequestFeedback",
+ ],
+ // Origin used by makeDummyRequest() & makeDummyRule():
+ host_permissions: ["https://example.com/"],
+ },
+ background: async dnrTestUtils => {
+ const { testCanUseAction, testCanMatchAnyBlock } = dnrTestUtils;
+
+ // declarativeNetRequestWithHostAccess + host permissions allows all:
+ await testCanUseAction("allow", true);
+ await testCanUseAction("allowAllRequests", true);
+ await testCanUseAction("block", true);
+ await testCanUseAction("upgradeScheme", true);
+ await testCanUseAction("redirect", true);
+ await testCanUseAction("modifyHeaders", true);
+
+ const url = "https://example.com/";
+ const urlNoPerm = "https://example.net/?not_in:host_permissions";
+ await testCanMatchAnyBlock({
+ matchedRequests: [
+ { url, type: "other" },
+ { url, type: "main_frame" },
+ { url, type: "sub_frame" },
+ // Navigations do no require host permissions for initiator.
+ { url, initiator: urlNoPerm, type: "main_frame" },
+ { url, initiator: urlNoPerm, type: "sub_frame" },
+ ],
+ nonMatchedRequests: [
+ // url always requires declarativeNetRequest or host permissions.
+ { url: urlNoPerm, type: "other" },
+ // Non-navigations require host permissions for initiator.
+ { url, initiator: urlNoPerm, type: "other" },
+ ],
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// Tests: resourceTypes, excludedResourceTypes
+// Tests: requestMethods, excludedRequestMethods
+add_task(async function match_condition_types_and_methods() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const dnr = browser.declarativeNetRequest;
+ const { makeDummyAction, testMatchesRequest } = dnrTestUtils;
+
+ // "modifyHeaders" is the only action that allows multiple rule matches.
+ const action = makeDummyAction("modifyHeaders");
+
+ await dnr.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: {
+ resourceTypes: ["xmlhttprequest"],
+ requestMethods: ["put"],
+ },
+ action,
+ },
+ {
+ id: 2,
+ condition: {
+ excludedResourceTypes: ["sub_frame"],
+ excludedRequestMethods: ["post"],
+ },
+ action,
+ },
+ {
+ id: 3,
+ condition: {
+ // resourceTypes not specified should imply all-minus-main_frame.
+ requestMethods: ["get", "post"],
+ },
+ action,
+ },
+ {
+ id: 4,
+ condition: {
+ resourceTypes: ["main_frame", "xmlhttprequest"],
+ excludedRequestMethods: ["get"],
+ },
+ action,
+ },
+ ],
+ });
+
+ const url = "https://example.com/some-dummy-url";
+ await testMatchesRequest(
+ { url, type: "main_frame" },
+ [2],
+ "main_frame + GET"
+ );
+
+ await testMatchesRequest(
+ { url, type: "xmlhttprequest" },
+ [2, 3],
+ "xmlhttprequest + GET"
+ );
+
+ await testMatchesRequest(
+ { url, type: "xmlhttprequest", method: "put" },
+ [1, 2, 4],
+ "xmlhttprequest + PUT"
+ );
+
+ await testMatchesRequest(
+ { url, type: "sub_frame", method: "post" },
+ [3],
+ "sub_frame + POST"
+ );
+
+ await testMatchesRequest(
+ { url, type: "sub_frame", method: "post" },
+ [3],
+ "sub_frame + POST"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// Tests: requestDomains, excludedRequestDomains
+add_task(async function match_request_domains() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const dnr = browser.declarativeNetRequest;
+ const { makeDummyAction, testMatchesRequest } = dnrTestUtils;
+
+ // "modifyHeaders" is the only action that allows multiple rule matches.
+ const action = makeDummyAction("modifyHeaders");
+
+ await dnr.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: {
+ requestDomains: ["a.com", "www.b.com"],
+ },
+ action,
+ },
+ {
+ id: 2,
+ condition: {
+ excludedRequestDomains: ["a.com", "www.b.com", "127.0.0.1"],
+ },
+ action,
+ },
+ {
+ id: 3,
+ condition: {
+ requestDomains: ["one.net"],
+ excludedRequestDomains: ["sub.one.net"],
+ },
+ action,
+ },
+ {
+ id: 4,
+ condition: {
+ // This can never match.
+ requestDomains: ["sub.one.net"],
+ excludedRequestDomains: ["one.net"],
+ },
+ action,
+ },
+ {
+ id: 5,
+ condition: {
+ requestDomains: ["127.0.0.1", "[::1]"],
+ },
+ action,
+ },
+ {
+ id: 6,
+ condition: {
+ requestDomains: [
+ "~b.com", // "~" should not be interpreted as pattern negation.
+ ],
+ },
+ action,
+ },
+ {
+ id: 7,
+ condition: {
+ // A canonical domain does not start with a ".". Domains filters
+ // starting with a "." are therefore not matching anything.
+ requestDomains: [".a.com"],
+ },
+ action,
+ },
+ ],
+ });
+
+ const type = "sub_frame";
+ // Tests related to a.com:
+ await testMatchesRequest(
+ { url: "https://a.com:1234/path", type },
+ [1],
+ "a.com: url's domain is equal to a.com"
+ );
+ await testMatchesRequest(
+ { url: "http://sub.a.com/", type },
+ [1],
+ "sub.a.com: url is subdomain of a.com"
+ );
+ await testMatchesRequest(
+ { url: "http://nota.com/a.com?a.com#a.com", type },
+ [2],
+ "nota.com: url's domain does not match a.com"
+ );
+ await testMatchesRequest(
+ { url: "http://a.com.not/a.com?a.com#a.com", type },
+ [2],
+ "a.com.not: url's domain does not match a.com"
+ );
+ await testMatchesRequest(
+ { url: "http://a.com./a.com?a.com#a.com", type },
+ [2],
+ "a.com.: url's domain (ending with dot) does not match a.com"
+ );
+
+ // Tests related to www.b.com:
+ await testMatchesRequest(
+ { url: "http://www.b.com/", type },
+ [1],
+ "www.b.com: url's domain is equal to www.b.com"
+ );
+ await testMatchesRequest(
+ { url: "http://sub.www.b.com", type },
+ [1],
+ "sub.www.b.com: url's domain is a subdomain of www.b.com"
+ );
+ await testMatchesRequest(
+ { url: "http://b.com/", type },
+ [2],
+ "b.com: url's domain is a superdomain, NOT a subdomain of www.b.com"
+ );
+
+ // Tests related to sub.one.net / one.net
+ await testMatchesRequest(
+ { url: "http://one.net/", type },
+ [2, 3],
+ "one.net: url's domain matches one.net, but not sub.one.net"
+ );
+ await testMatchesRequest(
+ { url: "http://sub.one.net/", type },
+ [2], // Rule 4 was a candidate, but excluded anyway.
+ "sub.one.net: url's domain matches sub.one.net, but excluded by one.net"
+ );
+
+ // Tests related to IP addresses
+ await testMatchesRequest(
+ { url: "http://127.0.0.1:8080/", type },
+ [5],
+ "127.0.0.1: IP address is exact match for 127.0.0.1"
+ );
+ await testMatchesRequest(
+ { url: "http://8.8.8.8/", type },
+ [2],
+ "8.8.8.8: not matched by any of the domains"
+ );
+ await testMatchesRequest(
+ { url: "http://9.127.0.0.1/", type },
+ [5],
+ "9.127.0.0.1: while not a valid IP, it looks like a subdomain"
+ );
+ await testMatchesRequest(
+ { url: "http://[::1]/", type },
+ [2, 5],
+ "[::1]: IPv6 matches with bracket"
+ );
+
+ // For completeness, verify that the non-resolving domain "~b.com"
+ // matches the input, so that we know that "~" was not given special
+ // treatment. In filter list syntax, "~" before the domain negates the
+ // meaning, but that should not be supported in DNR.
+ await testMatchesRequest(
+ { url: "http://~b.com/", type },
+ [2, 6],
+ "~b.com: Although a non-resolving domain, it matches the pattern"
+ );
+
+ // match_initiator_domains has more tests; here we just confirm that
+ // requestDomains rules don't match initiator.
+ await testMatchesRequest(
+ { url: "http://url.does.not.match/", type, initiator: "http://a.com/" },
+ [2],
+ "requestDomains should not match initiator URL"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function match_request_domains_punycode() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const dnr = browser.declarativeNetRequest;
+ const { makeDummyAction, testMatchesRequest } = dnrTestUtils;
+
+ // "modifyHeaders" is the only action that allows multiple rule matches.
+ const action = makeDummyAction("modifyHeaders");
+
+ // Note that the non-punycode domains are rejected by schema validation,
+ // and checked by test validate_domains in test_ext_dnr_session_rules.js.
+
+ await dnr.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: {
+ // straß.de
+ requestDomains: ["xn--stra-yna.de"],
+ },
+ action,
+ },
+ {
+ id: 2,
+ condition: {
+ // IDNA2003 converted ß to ss. But IDNA2008 requires punycode.
+ requestDomains: ["strass.de", "stras.de"],
+ },
+ action,
+ },
+ ],
+ });
+
+ const type = "sub_frame";
+
+ await testMatchesRequest(
+ { url: "https://straß.de/", type },
+ [1],
+ "straß.de matches"
+ );
+ await testMatchesRequest(
+ { url: "https://xn--stra-yna.de/", type },
+ [1],
+ "xn--stra-yna.de matches"
+ );
+ await testMatchesRequest(
+ { url: "https://strass.de/", type },
+ [2],
+ "strass.de does not match the punycode pattern of straß"
+ );
+ await testMatchesRequest(
+ { url: "https://stras.de/", type },
+ [2],
+ "stras.de does not match the punycode pattern of straß"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// Tests: initiatorDomains, excludedInitiatorDomains
+// More tests in: match_initiator_moz_extension.
+add_task(async function match_initiator_domains() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const dnr = browser.declarativeNetRequest;
+ const { makeDummyAction, testMatchesRequest } = dnrTestUtils;
+
+ // "modifyHeaders" is the only action that allows multiple rule matches.
+ const action = makeDummyAction("modifyHeaders");
+
+ // The validation of initiatorDomains and requestDomains are shared.
+ // The match_request_domains and match_request_domains_punycode tests
+ // already verify semantics; this test just tests that the conditional
+ // logic works as expected, plus coverage for initiator being void.
+
+ await dnr.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: {
+ initiatorDomains: ["a.com"],
+ },
+ action,
+ },
+ {
+ id: 2,
+ condition: {
+ excludedInitiatorDomains: ["a.com"],
+ },
+ action,
+ },
+ {
+ id: 3,
+ condition: {
+ initiatorDomains: ["c.com"],
+ excludedInitiatorDomains: ["c.com"],
+ },
+ action,
+ },
+ {
+ id: 4, // To verify that it does not match a void initiator.
+ condition: {
+ initiatorDomains: ["null"],
+ },
+ action,
+ },
+ {
+ id: 5,
+ condition: {
+ excludedInitiatorDomains: ["null", "undefined"],
+ },
+ action,
+ },
+ {
+ id: 6, // To verify that it does not match a void initiator.
+ condition: {
+ initiatorDomains: ["undefined"],
+ },
+ action,
+ },
+ ],
+ });
+
+ const url = "https://do.not.look.here/look_at_initator_instead";
+ const type = "image";
+ await testMatchesRequest(
+ { url, type, initiator: "http://a.com/" },
+ [1, 5],
+ "initiatorDomains matches"
+ );
+ await testMatchesRequest(
+ { url, type, initiator: "http://b.com/" },
+ [2, 5],
+ "excludedInitiatorDomains does not match, so request matched"
+ );
+ await testMatchesRequest(
+ { url, type, initiator: "http://c.com/" },
+ [2, 5], // 3 is not here, despite containing "c.com".
+ "excludedInitiatorDomains takes precedence over initiatorDomains"
+ );
+ // When initiator is not specified, rules with initiatorDomains should not
+ // match, and rules with excludedInitiatorDomains may match.
+ await testMatchesRequest(
+ { url, type },
+ [2, 5],
+ "request without initiator matches every excludedInitiatorDomains"
+ );
+ // http://null is unlikely to exist in practice. Regardless, verify that
+ // it won't match a void initiators.
+ await testMatchesRequest(
+ { url, type, initiator: "http://null/" },
+ [2, 4],
+ "http://null is matched by the 'null' domain"
+ );
+ await testMatchesRequest(
+ { url, type, initiator: "http://undefined/" },
+ [2, 6],
+ "http://null is matched by the 'undefined' domain"
+ );
+ await testMatchesRequest(
+ { url: "http://a.com/", type },
+ [2, 5],
+ "initiatorDomains should not match the request URL (initiator=null)"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// Tests: initiatorDomains, excludedInitiatorDomains with moz-extension:-URLs.
+add_task(async function match_initiator_moz_extension() {
+ let extension = await runAsDNRExtension({
+ manifest: { browser_specific_settings: { gecko: { id: "other@ext" } } },
+ background: async dnrTestUtils => {
+ const dnr = browser.declarativeNetRequest;
+ const { makeDummyAction, testMatchesRequest } = dnrTestUtils;
+
+ // "modifyHeaders" is the only action that allows multiple rule matches.
+ // But we cannot use "modifyHeaders" because that feature depends on
+ // access to "triggering principal". Fortunately, the two test rules in
+ // this test case are mutually exclusive, so the block action works.
+ // TODO bug 1825824: change to makeDummyAction("modifyHeaders").
+ const action = makeDummyAction("block");
+
+ const thisExtensionUUID = location.hostname;
+ await dnr.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: {
+ initiatorDomains: [thisExtensionUUID],
+ },
+ action,
+ },
+ {
+ id: 2,
+ condition: {
+ excludedInitiatorDomains: [thisExtensionUUID],
+ },
+ action,
+ },
+ ],
+ });
+
+ const url = "https://do.not.look.here/look_at_initator_instead";
+ const type = "other";
+ // Sanity check with non-moz-extension:-schemes as initiator.
+ await testMatchesRequest(
+ { url, type, initiator: `https://${thisExtensionUUID}/` },
+ [1],
+ "https:+UUID matches initiatorDomains"
+ );
+ await testMatchesRequest(
+ { url, type, initiator: "https://random-uuid-here/" },
+ [2],
+ "https:+UUID matches excludedInitiatorDomains"
+ );
+ // Now test with moz-extension: as initiator.
+ await testMatchesRequest(
+ { url, type, initiator: location.origin },
+ [1],
+ "moz-extension: initiator matches when it should"
+ );
+ await testMatchesRequest(
+ { url, type, initiator: `moz-extension://random-uuid-here/` },
+ [],
+ "moz-extension: from unrelated extension cannot match by default"
+ );
+
+ browser.test.onMessage.addListener(async msg => {
+ browser.test.assertEq("test_with_pref", msg, "expected msg");
+ await testMatchesRequest(
+ { url, type, initiator: `moz-extension://random-uuid-here/` },
+ [2],
+ "With pref, moz-extension: from unrelated extension can match"
+ );
+ browser.test.sendMessage("test_with_pref:done");
+ });
+
+ // Notify to continue. We don't exit yet due to unloadTestAtEnd:false
+ browser.test.notifyPass();
+ },
+ // Continue running the DNR extension because we want to test the current
+ // DNR rules with other extensions.
+ unloadTestAtEnd: false,
+ });
+
+ info("Testing foreign moz-extension request within same ext, with pref on");
+ await runWithPrefs(
+ [["extensions.dnr.match_requests_from_other_extensions", true]],
+ async () => {
+ extension.sendMessage("test_with_pref");
+ await extension.awaitMessage("test_with_pref:done");
+ }
+ );
+
+ const otherExtensionUUID = extension.uuid;
+
+ await runAsDNRExtension({
+ manifest: {
+ // Pass the DNR extension UUID to this extension.
+ description: otherExtensionUUID,
+ },
+ background: async () => {
+ const otherExtensionUUID = browser.runtime.getManifest().description;
+ const dnr = browser.declarativeNetRequest;
+
+ const url = "https://do.not.look.here/look_at_initator_instead";
+ const type = "other";
+
+ browser.test.assertDeepEq(
+ { matchedRules: [] },
+ await dnr.testMatchOutcome({ url, type, initiator: location.origin }),
+ "testMatchOutcome excludes other extensions by default"
+ );
+ browser.test.assertDeepEq(
+ { matchedRules: [] },
+ await dnr.testMatchOutcome(
+ { url, type, initiator: location.origin },
+ { includeOtherExtensions: true }
+ ),
+ "No matches when initiator is moz-extension:, different from DNR ext"
+ );
+ browser.test.assertDeepEq(
+ {
+ matchedRules: [
+ { ruleId: 1, rulesetId: "_session", extensionId: "other@ext" },
+ ],
+ },
+ await dnr.testMatchOutcome(
+ { url, type, initiator: `moz-extension://${otherExtensionUUID}` },
+ { includeOtherExtensions: true }
+ ),
+ "Simulated moz-extension: for original extension finds a match"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+
+ info("Testing foreign moz-extension request in other ext, with pref on");
+ await runWithPrefs(
+ [["extensions.dnr.match_requests_from_other_extensions", true]],
+ async () => {
+ await runAsDNRExtension({
+ manifest: {
+ // Pass the DNR extension UUID to this extension.
+ description: otherExtensionUUID,
+ },
+ background: async () => {
+ const otherExtensionUUID = browser.runtime.getManifest().description;
+ const dnr = browser.declarativeNetRequest;
+
+ const url = "https://do.not.look.here/look_at_initator_instead";
+ const type = "other";
+
+ // Sanity check: testMatchOutcome for moz-extension:-URL different
+ // from the DNR extension and the current test extension.
+ browser.test.assertDeepEq(
+ {
+ matchedRules: [
+ { ruleId: 2, rulesetId: "_session", extensionId: "other@ext" },
+ ],
+ },
+ await dnr.testMatchOutcome(
+ { url, type, initiator: "moz-extension://random-uuid-here/" },
+ { includeOtherExtensions: true }
+ ),
+ "With pref, moz-extension: from unrelated extensions can match"
+ );
+
+ // Usually, DNR does not affect requests from other extensions. That
+ // was checked in the previous test extension (without pref override).
+ // Here, we check that with the pref override, testMatchOutcome can
+ // return matches from other extensions for the given extension UUID.
+ browser.test.assertDeepEq(
+ {
+ matchedRules: [
+ { ruleId: 2, rulesetId: "_session", extensionId: "other@ext" },
+ ],
+ },
+ await dnr.testMatchOutcome(
+ { url, type, initiator: location.origin },
+ { includeOtherExtensions: true }
+ ),
+ "With pref, moz-extension:-initiator different from DNR ext matches"
+ );
+
+ // Identical test as in the previous test extension (that ran without
+ // the pref override). This verifies that the pref does not affect the
+ // behavior of request matching for requests within that extension.
+ browser.test.assertDeepEq(
+ {
+ matchedRules: [
+ { ruleId: 1, rulesetId: "_session", extensionId: "other@ext" },
+ ],
+ },
+ await dnr.testMatchOutcome(
+ { url, type, initiator: `moz-extension://${otherExtensionUUID}` },
+ { includeOtherExtensions: true }
+ ),
+ "With pref, moz-extension: for DNR ext still matches"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+ }
+ );
+
+ await extension.unload();
+});
+
+// Tests: urlFilter. For more comprehensive tests, see
+// toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js
+add_task(async function match_urlFilter() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const dnr = browser.declarativeNetRequest;
+ const { makeDummyAction, testMatchesRequest } = dnrTestUtils;
+
+ // "modifyHeaders" is the only action that allows multiple rule matches.
+ const action = makeDummyAction("modifyHeaders");
+
+ await dnr.updateSessionRules({
+ addRules: [
+ // Some patterns that match literally everything:
+ { id: 1, condition: { urlFilter: "." }, action },
+ { id: 2, condition: { urlFilter: "^" }, action },
+ { id: 3, condition: { urlFilter: "|" }, action },
+ // Patterns that match the test URLs
+ { id: 4, condition: { urlFilter: "https://example.com" }, action },
+ {
+ // urlFilter matches, requestDomains matches.
+ id: 5,
+ condition: { urlFilter: "*", requestDomains: ["example.com"] },
+ action,
+ },
+ {
+ // urlFilter matches, requestDomains does not match.
+ id: 6,
+ condition: { urlFilter: "*", requestDomains: ["notexample.com"] },
+ action,
+ },
+ {
+ // urlFilter does not match, requestDomains matches.
+ id: 7,
+ condition: { urlFilter: "notm", requestDomains: ["example.com"] },
+ action,
+ },
+ ],
+ });
+
+ await testMatchesRequest(
+ { url: "https://example.com/file.txt", type: "font" },
+ [1, 2, 3, 4, 5],
+ "urlFilter should match when needed, and correctly with requestDomains"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// Tests: regexFilter. For more comprehensive tests, see
+// toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js
+add_task(async function match_regexFilter() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const dnr = browser.declarativeNetRequest;
+ const { makeDummyAction, testMatchesRequest } = dnrTestUtils;
+
+ // "modifyHeaders" is the only action that allows multiple rule matches.
+ const action = makeDummyAction("modifyHeaders");
+
+ await dnr.updateSessionRules({
+ addRules: [
+ // Some patterns that match literally everything:
+ { id: 1, condition: { regexFilter: ".*" }, action },
+ { id: 2, condition: { regexFilter: "^" }, action },
+ // Patterns that match the test URLs
+ { id: 3, condition: { regexFilter: "https://.xample\\." }, action },
+ { id: 4, condition: { regexFilter: "https://example.com" }, action },
+ {
+ // regexFilter matches, requestDomains matches.
+ id: 5,
+ condition: { regexFilter: "$", requestDomains: ["example.com"] },
+ action,
+ },
+ {
+ // regexFilter matches, requestDomains does not match.
+ id: 6,
+ condition: { regexFilter: "$", requestDomains: ["notexample.com"] },
+ action,
+ },
+ {
+ // regexFilter does not match, requestDomains matches.
+ id: 7,
+ condition: { regexFilter: "notm", requestDomains: ["example.com"] },
+ action,
+ },
+ ],
+ });
+
+ await testMatchesRequest(
+ { url: "https://example.com/file.txt", type: "font" },
+ [1, 2, 3, 4, 5],
+ "regexFilter should match when needed, and correctly with requestDomains"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// Tests: tabIds, excludedTabIds
+add_task(async function match_tabIds() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const dnr = browser.declarativeNetRequest;
+ const { makeDummyAction, testMatchesRequest } = dnrTestUtils;
+
+ // "modifyHeaders" is the only action that allows multiple rule matches.
+ const action = makeDummyAction("modifyHeaders");
+
+ await dnr.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: {
+ excludedTabIds: [-1, Number.MAX_SAFE_INTEGER],
+ },
+ action,
+ },
+ {
+ id: 2,
+ condition: {
+ tabIds: [1, Number.MAX_SAFE_INTEGER],
+ },
+ action,
+ },
+ {
+ id: 3,
+ condition: {
+ tabIds: [-1],
+ },
+ action,
+ },
+ ],
+ });
+
+ const url = "https://example.com/some-dummy-url";
+ const type = "font";
+ await testMatchesRequest({ url, type }, [3], "tabId defaults to -1");
+ await testMatchesRequest({ url, type, tabId: -1 }, [3], "tabId -1");
+ await testMatchesRequest({ url, type, tabId: 1 }, [1, 2], "tabId 1");
+ await testMatchesRequest(
+ {
+ url,
+ type,
+ tabId: Number.MAX_SAFE_INTEGER,
+ },
+ [2],
+ `tabId high number (MAX_SAFE_INTEGER=${Number.MAX_SAFE_INTEGER})`
+ );
+
+ // tabId -2 is invalid and not encountered in practice, but technically
+ // it matches the first rule.
+ await testMatchesRequest({ url, type, tabId: -2 }, [1], "bad tabId -2");
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function action_precedence_between_extensions() {
+ // This test is structured as follows:
+ // - otherExtension registers rules for several numeric conditions (tabId).
+ // - otherExtensionNonBlockAndModifyHeaders adds allowAllRequests and
+ // modifyHeaders to all requests.
+ // - otherExtensionModifyHeaders adds modifyHeaders rules to all requests.
+ // - the main test extension also registers rules, and then simulates requests
+ // with testMatchOutcome for each tabId, and checks the result.
+
+ let otherExtension = await runAsDNRExtension({
+ manifest: { browser_specific_settings: { gecko: { id: "other@ext" } } },
+ background: async dnrTestUtils => {
+ const { makeDummyAction } = dnrTestUtils;
+
+ // Dummy condition for testing requests in this test.
+ const c = tabId => ({ resourceTypes: ["main_frame"], tabIds: [tabId] });
+
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ { id: 11, condition: c(1), action: makeDummyAction("allow") },
+ { id: 12, condition: c(2), action: makeDummyAction("block") },
+ { id: 13, condition: c(3), action: makeDummyAction("redirect") },
+ { id: 14, condition: c(4), action: makeDummyAction("upgradeScheme") },
+ {
+ id: 15,
+ condition: c(5),
+ action: makeDummyAction("allowAllRequests"),
+ },
+ {
+ id: 16,
+ condition: c(6),
+ action: makeDummyAction("allowAllRequests"),
+ },
+ ],
+ });
+ // Notify to continue. We don't exit yet due to unloadTestAtEnd:false
+ browser.test.notifyPass();
+ },
+ unloadTestAtEnd: false,
+ });
+
+ let otherExtensionNonBlockAndModifyHeaders = await runAsDNRExtension({
+ manifest: { browser_specific_settings: { gecko: { id: "other@ext2" } } },
+ background: async dnrTestUtils => {
+ const { makeDummyAction } = dnrTestUtils;
+
+ // Matches all requests from this test.
+ const condition = { resourceTypes: ["main_frame"] };
+
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1000,
+ condition,
+ action: makeDummyAction("modifyHeaders"),
+ // Same-or-lower priority "modifyHeaders" actions are ignored when
+ // an "allowAllRequests" action exists within the same extension.
+ // Since we have such a rule (ID 1001), this modifyHeaders rule must
+ // have "priority: 2" to avoid being ignored.
+ priority: 2,
+ },
+ { id: 1001, condition, action: makeDummyAction("allowAllRequests") },
+ {
+ id: 1002,
+ condition,
+ action: makeDummyAction("modifyHeaders"),
+ priority: 2, // necessary as explained above at rule ID 1000.
+ },
+ // should never appear because the first allowAllRequests rule should
+ // take precedence:
+ { id: 1003, condition, action: makeDummyAction("allowAllRequests") },
+ ],
+ });
+
+ // Notify to continue. We don't exit yet due to unloadTestAtEnd:false
+ browser.test.notifyPass();
+ },
+ unloadTestAtEnd: false,
+ });
+
+ // |otherExtensionModifyHeaders| and |otherExtensionNonBlockAndModifyHeaders|
+ // both have "modifyHeaders" rules. The documented order of rules is for
+ // the most recently installed extension to take precedence when applying
+ // modifyHeaders actions. The "priority" key is extension-specific, so even
+ // though |otherExtensionNonBlockAndModifyHeaders| defines "priority: 2" for
+ // modifyHeaders action (ID 1001), the modifyHeaders below (ID 1337) takes
+ // precedence because the extension was installed later.
+ let otherExtensionModifyHeaders = await runAsDNRExtension({
+ manifest: { browser_specific_settings: { gecko: { id: "other@ext3" } } },
+ background: async dnrTestUtils => {
+ const { makeDummyAction } = dnrTestUtils;
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1337,
+ // Matches all requests from this test.
+ condition: { resourceTypes: ["main_frame"] },
+ action: makeDummyAction("modifyHeaders"),
+ // Note: no "priority" key set, so defaults to 1.
+ },
+ ],
+ });
+ // Notify to continue. We don't exit yet due to unloadTestAtEnd:false
+ browser.test.notifyPass();
+ },
+ unloadTestAtEnd: false,
+ });
+
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const dnr = browser.declarativeNetRequest;
+ const { makeDummyAction } = dnrTestUtils;
+
+ // Dummy condition for testing requests in this test.
+ const c = tabId => ({ resourceTypes: ["main_frame"], tabIds: [tabId] });
+
+ await dnr.updateSessionRules({
+ addRules: [
+ { id: 91, condition: c(1), action: makeDummyAction("block") },
+ { id: 92, condition: c(2), action: makeDummyAction("allow") },
+ { id: 93, condition: c(3), action: makeDummyAction("block") },
+ { id: 94, condition: c(4), action: makeDummyAction("block") },
+ { id: 95, condition: c(5), action: makeDummyAction("allow") },
+ {
+ id: 96,
+ condition: c(6),
+ action: makeDummyAction("allowAllRequests"),
+ },
+ ],
+ });
+
+ const url = "https://example.com/dummy-url";
+ const type = "main_frame";
+ const options = { includeOtherExtensions: true };
+ browser.test.assertDeepEq(
+ [{ ruleId: 91, rulesetId: "_session" }],
+ (await dnr.testMatchOutcome({ url, type, tabId: 1 }, options))
+ .matchedRules,
+ "block takes precedence over allow (from other extension)"
+ );
+
+ browser.test.assertDeepEq(
+ [{ ruleId: 12, rulesetId: "_session", extensionId: "other@ext" }],
+ (await dnr.testMatchOutcome({ url, type, tabId: 2 }, options))
+ .matchedRules,
+ "block (from other extension) takes precedence over allow"
+ );
+ browser.test.assertDeepEq(
+ [{ ruleId: 93, rulesetId: "_session" }],
+ (await dnr.testMatchOutcome({ url, type, tabId: 3 }, options))
+ .matchedRules,
+ "block takes precedence over redirect (from other extension)"
+ );
+ browser.test.assertDeepEq(
+ [{ ruleId: 94, rulesetId: "_session" }],
+ (await dnr.testMatchOutcome({ url, type, tabId: 4 }, options))
+ .matchedRules,
+ "block takes precedence over upgradeScheme (from other extension)"
+ );
+ browser.test.assertDeepEq(
+ [
+ // allow:
+ { ruleId: 95, rulesetId: "_session" },
+ // allowAllRequests (newest install first):
+ { ruleId: 1001, rulesetId: "_session", extensionId: "other@ext2" },
+ { ruleId: 15, rulesetId: "_session", extensionId: "other@ext" },
+ // modifyHeaders (see comment at otherExtensionModifyHeaders):
+ { ruleId: 1337, rulesetId: "_session", extensionId: "other@ext3" },
+ { ruleId: 1000, rulesetId: "_session", extensionId: "other@ext2" },
+ { ruleId: 1002, rulesetId: "_session", extensionId: "other@ext2" },
+ ],
+ (await dnr.testMatchOutcome({ url, type, tabId: 5 }, options))
+ .matchedRules,
+ "When allow matches, allowAllRequests from other extension matches too"
+ );
+ browser.test.assertDeepEq(
+ [
+ // allowAllRequests (newest install first):
+ { ruleId: 96, rulesetId: "_session" },
+ { ruleId: 1001, rulesetId: "_session", extensionId: "other@ext2" },
+ { ruleId: 16, rulesetId: "_session", extensionId: "other@ext" },
+ // modifyHeaders (see comment at otherExtensionModifyHeaders):
+ { ruleId: 1337, rulesetId: "_session", extensionId: "other@ext3" },
+ { ruleId: 1000, rulesetId: "_session", extensionId: "other@ext2" },
+ { ruleId: 1002, rulesetId: "_session", extensionId: "other@ext2" },
+ ],
+ (await dnr.testMatchOutcome({ url, type, tabId: 6 }, options))
+ .matchedRules,
+ "allowAllRequests from all other extensions are matched"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+
+ await otherExtension.unload();
+ await otherExtensionNonBlockAndModifyHeaders.unload();
+ await otherExtensionModifyHeaders.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js
new file mode 100644
index 0000000000..dd12184cbe
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js
@@ -0,0 +1,1159 @@
+"use strict";
+
+add_setup(() => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.feedback", true);
+});
+
+// This function is serialized and called in the context of the test extension's
+// background page. dnrTestUtils is passed to the background function.
+function makeDnrTestUtils() {
+ const dnrTestUtils = {};
+ const dnr = browser.declarativeNetRequest;
+
+ const DUMMY_ACTION = {
+ // "modifyHeaders" is the only action that allows multiple rule matches.
+ type: "modifyHeaders",
+ responseHeaders: [{ operation: "append", header: "x", value: "y" }],
+ };
+ async function testMatchesRequest(request, ruleIds, description) {
+ browser.test.assertDeepEq(
+ ruleIds,
+ (await dnr.testMatchOutcome(request)).matchedRules.map(mr => mr.ruleId),
+ description
+ );
+ }
+ async function testMatchesUrlFilter({
+ urlFilter,
+ isUrlFilterCaseSensitive,
+ urls = [],
+ urlsNonMatching = [],
+ }) {
+ // Sanity check: verify that there are no unexpected escaped characters,
+ // because that can surprise.
+ function sanityCheckUrl(url) {
+ const normalizedUrl = new URL(url).href;
+ if (normalizedUrl.split("%").length !== url.split("*").length) {
+ // ^ we only check for %-escapes and not exact URL equality because the
+ // tests imported from Chrome often omit the "/" (path separator).
+ browser.test.assertEq(normalizedUrl, url, "url should be canonical");
+ }
+ }
+
+ await dnr.updateSessionRules({
+ addRules: [
+ {
+ id: 12345,
+ condition: { urlFilter, isUrlFilterCaseSensitive },
+ action: DUMMY_ACTION,
+ },
+ ],
+ });
+ for (let url of urls) {
+ sanityCheckUrl(url);
+ const request = { url, type: "other" };
+ const description = `urlFilter ${urlFilter} should match: ${url}`;
+ await testMatchesRequest(request, [12345], description);
+ }
+ for (let url of urlsNonMatching) {
+ sanityCheckUrl(url);
+ const request = { url, type: "other" };
+ const description = `urlFilter ${urlFilter} should not match: ${url}`;
+ await testMatchesRequest(request, [], description);
+ }
+ await dnr.updateSessionRules({ removeRuleIds: [12345] });
+ }
+ Object.assign(dnrTestUtils, {
+ DUMMY_ACTION,
+ testMatchesRequest,
+ testMatchesUrlFilter,
+ });
+ return dnrTestUtils;
+}
+
+async function runAsDNRExtension({ background, manifest }) {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${background})((${makeDnrTestUtils})())`,
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ // While testing urlFilter itself does not require any host permissions,
+ // we are asking for host permissions anyway because the "modifyHeaders"
+ // action requires host permissions, and we use the "modifyHeaders" action
+ // to ensure that we can detect when multiple rules match.
+ host_permissions: ["<all_urls>"],
+ granted_host_permissions: true,
+ ...manifest,
+ },
+ temporarilyInstalled: true, // <-- for granted_host_permissions
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+}
+
+// This test checks various urlFilters with a possibly ambiguous interpretation.
+// In some cases the semantic difference in interpretation can have different
+// outcomes; in these cases we have chosen the behavior as observed in Chrome.
+add_task(async function ambiguous_urlFilter_patterns() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testMatchesUrlFilter } = dnrTestUtils;
+
+ // Left anchor, empty pattern: always matches
+ // Ambiguous with Right anchor, but same result.
+ await testMatchesUrlFilter({
+ urlFilter: "|",
+ urls: ["http://a/"],
+ urlsNonMatching: [],
+ });
+
+ // Domain anchor, empty pattern: always matches.
+ // Ambiguous with Left anchor + Right anchor, the latter would not match
+ // anything (only an empty string, but URLs cannot be empty).
+ await testMatchesUrlFilter({
+ urlFilter: "||",
+ urls: ["http://a/"],
+ urlsNonMatching: [],
+ });
+
+ // Domain anchor plus Right separator: never matches.
+ // Ambiguous with Left anchor + | + Right anchor, that is no match either.
+ await testMatchesUrlFilter({
+ urlFilter: "|||",
+ urls: [],
+ urlsNonMatching: ["http://a./|||"],
+ });
+
+ // Repeated separator: ^^^^ matches separator chars (=everything except
+ // alphanumeric, "_", "-", ".", "%"), but when at the end of a string,
+ // the last "^" can also be interpreted as a right anchor (like ^^^|).
+ // Ambiguous: while "^" is defined to match the end of URL, it could also
+ // be interpreted as "^^^^" matching the end of URL 4x, i.e. always.
+ await testMatchesUrlFilter({
+ urlFilter: "^^^^",
+ urls: [
+ // Note: "^" is escaped "%5E" when part of the URL, except after "#".
+ "http://a/#frag^^^^", // four ^ characters ("^^^^").
+ "http://a/#frag^^^", // three ^ characters ("^^^") + end of URL.
+ "http://a/?&#", // four separator characters ("/?&#");
+ "http://a/#^", // three separator characters ("/??") + end of URL.
+ // ^ Note that "^" is after "#" and therefore not %5E. If "^" were to
+ // somehow be %-encoded to "%5E", then the end would become "/#%5E"
+ // and the "/#%" would only be 3 separators followed by alphanum. The
+ // test matching shows that the canonical representation of "^" after
+ // a "#" is "^" and can be matched.
+ ],
+ urlsNonMatching: [
+ "http://a/?", // Just two separator + end of URL, not matching 4x "^".
+ "http://a/____", // _ is specified to not match ^.
+ "http://a/----", // - is specified to not match ^.
+ "http://a/....", // . is specified to not match ^.
+ ],
+ });
+ // Not ambiguous, but for comparison with "^^^^": all http(s) match.
+ await testMatchesUrlFilter({
+ urlFilter: "^^^",
+ urls: ["https://a/"], // "://" always matches "^^^".
+ // Not seen by DNR in practice, but could be passed to testMatchOutcome:
+ urlsNonMatching: ["file:hello/no/three/consecutive/special/characters"],
+ });
+
+ // Separator plus Right anchor: always matches.
+ // Ambiguous: "^" is defined to match the end of URL once, but a right
+ // domain anchor already matches that. A potential interpretation is for
+ // "^" to be required to match a non-alphanumeric (etc.), but in practice
+ // "^" is allowed to match the end of the URL. Effectively "^|" = "|".
+ await testMatchesUrlFilter({
+ urlFilter: "^|",
+ urls: [
+ "http://a/", // "/" matches "^".
+ "http://a/a", // "a" does not match "^", but "^" matches the end.
+ ],
+ urlsNonMatching: [],
+ });
+
+ // Domain anchor plus separator: "^" only matches non-alphanum (etc.)
+ // Ambiguous: "||" is defined to match a domain anchor. There is no
+ // domain part after the trailing "." of a FQDN. Still, "." matches.
+ await testMatchesUrlFilter({
+ urlFilter: "||^",
+ urls: ["http://a./"], // FQDN: "/" after "." matches "^".
+ urlsNonMatching: ["http://a/", "http://a/||"],
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+add_task(async function urlFilter_domain_anchor() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testMatchesUrlFilter } = dnrTestUtils;
+
+ await testMatchesUrlFilter({
+ // Not a domain anchor, but for comparison with "||ps" below:
+ urlFilter: "ps",
+ urls: [
+ "https://example.com/", // ps in scheme.
+ "http://ps.example.com/", // ps at start of domain.
+ "http://sub.ps.example.com/", // ps at superdomain.
+ "http://ps/", // ps as sole host.
+ "http://example-ps.com/", // ps in middle of domain.
+ "http://ps@example.com/", // ps as user without password.
+ "http://user:ps@example.com/", // ps in password.
+ "http://ps:pass@example.com/", // ps in user.
+ "http://example.com/ps", // ps at end.
+ "http://example.com/#ps", // ps in fragment.
+ ],
+ urlsNonMatching: [
+ "http://example.com/", // no ps anywhere.
+ ],
+ });
+
+ await testMatchesUrlFilter({
+ urlFilter: "||ps",
+ urls: [
+ "http://ps.example.com/", // ps at start of domain.
+ "http://sub.ps.example.com/", // ps at superdomain.
+ "http://ps/", // ps as sole host.
+ ],
+ urlsNonMatching: [
+ "http://example.com/", // no ps anywhere.
+ "https://example.com/", // ps in scheme.
+ "http://example-ps.com/", // ps in middle
+ "http://ps@example.com/", // ps as user without password.
+ "http://user:ps@example.com/", // ps in password.
+ "http://ps:pass@example.com/", // ps in user.
+ "http://example.com/ps", // ps at end.
+ ],
+ });
+
+ await testMatchesUrlFilter({
+ urlFilter: "||1",
+ urls: [
+ "http://127.0.0.1/",
+ "http://2.0.0.1/",
+ "http://www.1example.com/",
+ ],
+ urlsNonMatching: [
+ "http://[::1]/",
+ "http://[1::1]/",
+ "http://hostwithport:1/",
+ "http://host/1",
+ "http://fqdn.:1/",
+ "http://fqdn./1",
+ ],
+ });
+
+ await testMatchesUrlFilter({
+ urlFilter: "||^1",
+ urls: [
+ "http://[1::1]/", // "[1" at start matches "^1".
+ "http://fqdn.:1/", // ":1" matches "^1" and is after a ".".
+ "http://fqdn./1", // "/1" matches "^1" and is after a ".".
+ ],
+ urlsNonMatching: [
+ "http://127.0.0.1/",
+ "http://2.0.0.1/",
+ "http://www.1example.com/",
+ "http://[::1]/",
+ "http://hostwithport:1/",
+ "http://host/1",
+ ],
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// Extreme patterns that should not be used in practice, but are not explicitly
+// documented to be disallowed.
+add_task(
+ // Stuck in ccov: https://bugzilla.mozilla.org/show_bug.cgi?id=1806494#c4
+ { skip_if: () => mozinfo.ccov },
+ async function extreme_urlFilter_patterns() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testMatchesRequest, DUMMY_ACTION } = dnrTestUtils;
+
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: {
+ urlFilter: "*".repeat(1e6),
+ },
+ action: DUMMY_ACTION,
+ },
+ {
+ id: 2,
+ condition: {
+ urlFilter: "^".repeat(1e6),
+ },
+ action: DUMMY_ACTION,
+ },
+ {
+ id: 3,
+ condition: {
+ // Note: 2 chars repeat 5e5 instead of 1e6 because newURI limits
+ // the length of the URL (to network.standard-url.max-length),
+ // so we would not be able to verify whether the URL is really
+ // that long.
+ urlFilter: "*^".repeat(5e5),
+ },
+ action: DUMMY_ACTION,
+ },
+ {
+ id: 4,
+ condition: {
+ // Note: well beyond the maximum length of a URL. But as "*" can
+ // match any char (including zero length), this still matches.
+ urlFilter: "h" + "*".repeat(1e7) + "endofurl",
+ },
+ action: DUMMY_ACTION,
+ },
+ ],
+ });
+
+ await testMatchesRequest(
+ { url: "http://example.com/", type: "other" },
+ [1],
+ "urlFilter with 1M wildcard chars matches any URL"
+ );
+
+ await testMatchesRequest(
+ { url: "http://example.com/" + "x".repeat(1e6), type: "other" },
+ [1],
+ "urlFilter with 1M wildcards matches, other '^' do not match alpha"
+ );
+
+ await testMatchesRequest(
+ { url: "http://example.com/" + "/".repeat(1e6), type: "other" },
+ [1, 2, 3],
+ "urlFilter with 1M wildcards, ^ and *^ all match URL with 1M '/' chars"
+ );
+
+ await testMatchesRequest(
+ { url: "http://example.com/" + "x/".repeat(5e5), type: "other" },
+ [1, 3],
+ "urlFilter with 1M wildcards and *^ match URL with 1M 'x/' chars"
+ );
+
+ await testMatchesRequest(
+ { url: "http://example.com/endofurl", type: "other" },
+ [1, 4],
+ "urlFilter with 1M and 10M wildcards matches URL"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+ }
+);
+
+add_task(async function test_isUrlFilterCaseSensitive() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testMatchesUrlFilter } = dnrTestUtils;
+
+ await testMatchesUrlFilter({
+ urlFilter: "AbC",
+ isUrlFilterCaseSensitive: true,
+ urls: [
+ "http://true.example.com/AbC", // Exact match.
+ ],
+ urlsNonMatching: [
+ "http://true.example.com/abc", // All lower.
+ "http://true.example.com/ABC", // All upper.
+ "http://true.example.com/???", // ABC not present at all.
+ "http://true.AbC/", // When canonicalized, the host is lower case.
+ ],
+ });
+ await testMatchesUrlFilter({
+ urlFilter: "AbC",
+ isUrlFilterCaseSensitive: false,
+ urls: [
+ "http://false.example.com/AbC", // Exact match.
+ "http://false.example.com/abc", // All lower.
+ "http://false.example.com/ABC", // All upper.
+ "http://false.AbC/", // When canonicalized, the host is lower case.
+ ],
+ urlsNonMatching: [
+ "http://false.example.com/???", // ABC not present at all.
+ ],
+ });
+
+ // Chrome's initial DNR API specified isUrlFilterCaseSensitive to be true
+ // by default. Later, it became false by default.
+ // https://github.com/w3c/webextensions/issues/269
+ await testMatchesUrlFilter({
+ urlFilter: "AbC",
+ // isUrlFilterCaseSensitive: false, // is implied by default.
+ urls: [
+ "http://default.example.com/AbC", // Exact match.
+ "http://default.example.com/abc", // All lower.
+ "http://default.example.com/ABC", // All upper.
+ "http://default.AbC/", // When canonicalized, the host is lower case.
+ ],
+ urlsNonMatching: [
+ "http://default.example.com/???", // ABC not present at all.
+ ],
+ });
+
+ browser.test.notifyPass();
+ },
+ });
+});
+
+// Imported tests from Chromium from:
+// https://chromium.googlesource.com/chromium/src.git/+/refs/tags/110.0.5442.0/components/url_pattern_index/url_pattern_unittest.cc
+// kAnchorNone -> "" (anywhere in the string)
+// kBoundary -> | (start or end of string)
+// kSubdomain -> || (start of (sub)domain)
+// kMatchCase -> isUrlFilterCaseSensitive: true
+// kDonotMatchCase -> isUrlFilterCaseSensitive: false (this is the default).
+// proto::URL_PATTERN_TYPE_WILDCARDED / proto::URL_PATTERN_TYPE_SUBSTRING -> ""
+//
+// Minus two tests ("", kBoundary, kBoundary) because the resulting pattern is
+// "||" and ambiguous with ("", kSubdomain, "").
+add_task(async function test_chrome_parity() {
+ await runAsDNRExtension({
+ background: async dnrTestUtils => {
+ const { testMatchesUrlFilter } = dnrTestUtils;
+ const testCases = [
+ // {"", proto::URL_PATTERN_TYPE_SUBSTRING}
+ {
+ urlFilter: "*",
+ url: "http://ex.com/",
+ expectMatch: true,
+ },
+ // // {"", proto::URL_PATTERN_TYPE_WILDCARDED}
+ // { // Already tested before.
+ // urlFilter: "*",
+ // url: "http://ex.com/",
+ // expectMatch: true,
+ // },
+ // {"", kBoundary, kAnchorNone}
+ {
+ urlFilter: "|",
+ url: "http://ex.com/",
+ expectMatch: true,
+ },
+ // {"", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||",
+ url: "http://ex.com/",
+ expectMatch: true,
+ },
+ // // {"", kSubdomain, kAnchorNone}
+ // { // Already tested before.
+ // urlFilter: "||",
+ // url: "http://ex.com/",
+ // expectMatch: true,
+ // },
+ // {"^", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||^",
+ url: "http://ex.com/",
+ expectMatch: false,
+ },
+ // {".", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||.",
+ url: "http://ex.com/",
+ expectMatch: false,
+ },
+ // // {"", kAnchorNone, kBoundary}
+ // { // Already tested before.
+ // urlFilter: "|",
+ // url: "http://ex.com/",
+ // expectMatch: true,
+ // },
+ // {"^", kAnchorNone, kBoundary}
+ {
+ urlFilter: "^|",
+ url: "http://ex.com/",
+ expectMatch: true,
+ },
+ // {".", kAnchorNone, kBoundary}
+ {
+ urlFilter: ".|",
+ url: "http://ex.com/",
+ expectMatch: false,
+ },
+ // // {"", kBoundary, kBoundary}
+ // { // "||" is ambiguous, cannot mean Left anchor + Right anchor
+ // urlFilter: "||",
+ // url: "http://ex.com/",
+ // expectMatch: false,
+ // },
+ // {"", kSubdomain, kBoundary}
+ {
+ urlFilter: "|||",
+ url: "http://ex.com/",
+ expectMatch: false,
+ },
+ // {"com/", kSubdomain, kBoundary}
+ {
+ urlFilter: "||com/|",
+ url: "http://ex.com/",
+ expectMatch: true,
+ },
+ // {"xampl", proto::URL_PATTERN_TYPE_SUBSTRING}
+ {
+ urlFilter: "xampl",
+ url: "http://example.com",
+ expectMatch: true,
+ },
+ // {"example", proto::URL_PATTERN_TYPE_SUBSTRING}
+ {
+ urlFilter: "example",
+ url: "http://example.com",
+ expectMatch: true,
+ },
+ // {"/a?a"}
+ {
+ urlFilter: "/a?a",
+ url: "http://ex.com/a?a",
+ expectMatch: true,
+ },
+ // {"^abc"}
+ {
+ urlFilter: "^abc",
+ url: "http://ex.com/abc?a",
+ expectMatch: true,
+ },
+ // {"^abc"}
+ {
+ urlFilter: "^abc",
+ url: "http://ex.com/a?abc",
+ expectMatch: true,
+ },
+ // {"^abc"}
+ {
+ urlFilter: "^abc",
+ url: "http://ex.com/abc?abc",
+ expectMatch: true,
+ },
+ // {"^abc^abc"}
+ {
+ urlFilter: "^abc^abc",
+ url: "http://ex.com/abc?abc",
+ expectMatch: true,
+ },
+ // {"^com^abc^abc"}
+ {
+ urlFilter: "^com^abc^abc",
+ url: "http://ex.com/abc?abc",
+ expectMatch: false,
+ },
+ // {"http://ex", kBoundary, kAnchorNone}
+ {
+ urlFilter: "|http://ex",
+ url: "http://example.com",
+ expectMatch: true,
+ },
+ // {"http://ex", kAnchorNone, kAnchorNone}
+ {
+ urlFilter: "http://ex",
+ url: "http://example.com",
+ expectMatch: true,
+ },
+ // {"mple.com/", kAnchorNone, kBoundary}
+ {
+ urlFilter: "mple.com/|",
+ url: "http://example.com",
+ expectMatch: true,
+ },
+ // {"mple.com/", kAnchorNone, kAnchorNone}
+ {
+ urlFilter: "mple.com/",
+ url: "http://example.com",
+ expectMatch: true,
+ },
+ // {"mple.com/", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||mple.com/",
+ url: "http://example.com",
+ expectMatch: false,
+ },
+ // {"ex.com", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||ex.com",
+ url: "http://hex.com",
+ expectMatch: false,
+ },
+ // {"ex.com", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||ex.com",
+ url: "http://ex.com",
+ expectMatch: true,
+ },
+ // {"ex.com", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||ex.com",
+ url: "http://hex.ex.com",
+ expectMatch: true,
+ },
+ // {"ex.com", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||ex.com",
+ url: "http://hex.hex.com",
+ expectMatch: false,
+ },
+ // {"example.com^", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||example.com^",
+ url: "http://www.example.com",
+ expectMatch: true,
+ },
+ // {"http://*mpl", kBoundary, kAnchorNone}
+ {
+ urlFilter: "|http://*mpl",
+ url: "http://example.com",
+ expectMatch: true,
+ },
+ // {"mpl*com/", kAnchorNone, kBoundary}
+ {
+ urlFilter: "mpl*com/|",
+ url: "http://example.com",
+ expectMatch: true,
+ },
+ // {"example^com"}
+ {
+ urlFilter: "example^com",
+ url: "http://example.com",
+ expectMatch: false,
+ },
+ // {"example^com"}
+ {
+ urlFilter: "example^com",
+ url: "http://example/com",
+ expectMatch: true,
+ },
+ // {"example.com^"}
+ {
+ urlFilter: "example.com^",
+ url: "http://example.com:8080",
+ expectMatch: true,
+ },
+ // {"http*.com/", kBoundary, kBoundary}
+ {
+ urlFilter: "|http*.com/|",
+ url: "http://example.com",
+ expectMatch: true,
+ },
+ // {"http*.org/", kBoundary, kBoundary}
+ {
+ urlFilter: "|http*.org/|",
+ url: "http://example.com",
+ expectMatch: false,
+ },
+ // {"/path?*&p1=*&p2="}
+ {
+ urlFilter: "/path?*&p1=*&p2=",
+ url: "http://ex.com/aaa/path/bbb?k=v&p1=0&p2=1",
+ expectMatch: false,
+ },
+ // {"/path?*&p1=*&p2="}
+ {
+ urlFilter: "/path?*&p1=*&p2=",
+ url: "http://ex.com/aaa/path?k=v&p1=0&p2=1",
+ expectMatch: true,
+ },
+ // {"/path?*&p1=*&p2="}
+ {
+ urlFilter: "/path?*&p1=*&p2=",
+ url: "http://ex.com/aaa/path?k=v&k=v&p1=0&p2=1",
+ expectMatch: true,
+ },
+ // {"/path?*&p1=*&p2="}
+ {
+ urlFilter: "/path?*&p1=*&p2=",
+ url: "http://ex.com/aaa/path?k=v&p1=0&p3=10&p2=1",
+ expectMatch: true,
+ },
+ // {"/path?*&p1=*&p2="}
+ {
+ urlFilter: "/path?*&p1=*&p2=",
+ url: "http://ex.com/aaa/path&p1=0&p2=1",
+ expectMatch: false,
+ },
+ // {"/path?*&p1=*&p2="}
+ {
+ urlFilter: "/path?*&p1=*&p2=",
+ url: "http://ex.com/aaa/path?k=v&p2=0&p1=1",
+ expectMatch: false,
+ },
+ // {"abc*def*ghijk*xyz"}
+ {
+ urlFilter: "abc*def*ghijk*xyz",
+ url: "http://example.com/abcdeffffghijkmmmxyzzz",
+ expectMatch: true,
+ },
+ // {"abc*cdef"}
+ {
+ urlFilter: "abc*cdef",
+ url: "http://example.com/abcdef",
+ expectMatch: false,
+ },
+ // {"^^a^^"}
+ {
+ urlFilter: "^^a^^",
+ url: "http://ex.com/?a=/",
+ expectMatch: true,
+ },
+ // {"^^a^^"}
+ {
+ urlFilter: "^^a^^",
+ url: "http://ex.com/?a=/&b=0",
+ expectMatch: true,
+ },
+ // {"^^a^^"}
+ {
+ urlFilter: "^^a^^",
+ url: "http://ex.com/?a=x",
+ expectMatch: false,
+ },
+ // {"^^a^^"}
+ {
+ urlFilter: "^^a^^",
+ url: "http://ex.com/?a=",
+ expectMatch: true,
+ },
+ // {"ex.com^path^*k=v^"}
+ {
+ urlFilter: "ex.com^path^*k=v^",
+ url: "http://ex.com/path/?k1=v1&ak=v&kk=vv",
+ expectMatch: true,
+ },
+ // {"ex.com^path^*k=v^"}
+ {
+ urlFilter: "ex.com^path^*k=v^",
+ url: "http://ex.com/p/path/?k1=v1&ak=v&kk=vv",
+ expectMatch: false,
+ },
+ // {"a^a&a^a&"}
+ {
+ urlFilter: "a^a&a^a&",
+ url: "http://ex.com/a/a/a/a/?a&a&a&a&a",
+ expectMatch: true,
+ },
+ // {"abc*def^"}
+ {
+ urlFilter: "abc*def^",
+ url: "http://ex.com/abc/a/ddef/",
+ expectMatch: true,
+ },
+ // {"https://example.com/"}
+ {
+ urlFilter: "https://example.com/",
+ url: "http://example.com/",
+ expectMatch: false,
+ },
+ // {"example.com/", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||example.com/",
+ url: "http://example.com/",
+ expectMatch: true,
+ },
+ // {"examp", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||examp",
+ url: "http://example.com/",
+ expectMatch: true,
+ },
+ // {"xamp", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||xamp",
+ url: "http://example.com/",
+ expectMatch: false,
+ },
+ // {"examp", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||examp",
+ url: "http://test.example.com/",
+ expectMatch: true,
+ },
+ // {"t.examp", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||t.examp",
+ url: "http://test.example.com/",
+ expectMatch: false,
+ },
+ // {"com^", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||com^",
+ url: "http://test.example.com/",
+ expectMatch: true,
+ },
+ // {"com^x", kSubdomain, kBoundary}
+ {
+ urlFilter: "||com^x|",
+ url: "http://a.com/x",
+ expectMatch: true,
+ },
+ // {"x.com", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||x.com",
+ url: "http://ex.com/?url=x.com",
+ expectMatch: false,
+ },
+ // {"ex.com/", kSubdomain, kBoundary}
+ {
+ urlFilter: "||ex.com/|",
+ url: "http://ex.com/",
+ expectMatch: true,
+ },
+ // {"ex.com^", kSubdomain, kBoundary}
+ {
+ urlFilter: "||ex.com^|",
+ url: "http://ex.com/",
+ expectMatch: true,
+ },
+ // {"ex.co", kSubdomain, kBoundary}
+ {
+ urlFilter: "||ex.co|",
+ url: "http://ex.com/",
+ expectMatch: false,
+ },
+ // {"ex.com", kSubdomain, kBoundary}
+ {
+ urlFilter: "||ex.com|",
+ url: "http://rex.com.ex.com/",
+ expectMatch: false,
+ },
+ // {"ex.com/", kSubdomain, kBoundary}
+ {
+ urlFilter: "||ex.com/|",
+ url: "http://rex.com.ex.com/",
+ expectMatch: true,
+ },
+ // {"http", kSubdomain, kBoundary}
+ {
+ urlFilter: "||http|",
+ url: "http://http.com/",
+ expectMatch: false,
+ },
+ // {"http", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||http",
+ url: "http://http.com/",
+ expectMatch: true,
+ },
+ // {"/example.com", kSubdomain, kBoundary}
+ {
+ urlFilter: "||/example.com|",
+ url: "http://example.com/",
+ expectMatch: false,
+ },
+ // {"/example.com/", kSubdomain, kBoundary}
+ {
+ urlFilter: "||/example.com/|",
+ url: "http://example.com/",
+ expectMatch: false,
+ },
+ // {".", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||.",
+ url: "http://a..com/",
+ expectMatch: true,
+ },
+ // {"^", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||^",
+ url: "http://a..com/",
+ expectMatch: false,
+ },
+ // {".", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||.",
+ url: "http://a.com./",
+ expectMatch: false,
+ },
+ // {"^", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||^",
+ url: "http://a.com./",
+ expectMatch: true,
+ },
+ // {".", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||.",
+ url: "http://a.com../",
+ expectMatch: true,
+ },
+ // {"^", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||^",
+ url: "http://a.com../",
+ expectMatch: true,
+ },
+ // {"/path", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||/path",
+ url: "http://a.com./path/to/x",
+ expectMatch: true,
+ },
+ // {"^path", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||^path",
+ url: "http://a.com./path/to/x",
+ expectMatch: true,
+ },
+ // {"/path", kSubdomain, kBoundary}
+ {
+ urlFilter: "||/path|",
+ url: "http://a.com./path",
+ expectMatch: true,
+ },
+ // {"^path", kSubdomain, kBoundary}
+ {
+ urlFilter: "||^path|",
+ url: "http://a.com./path",
+ expectMatch: true,
+ },
+ // {"path", kSubdomain, kBoundary}
+ {
+ urlFilter: "||path|",
+ url: "http://a.com./path",
+ expectMatch: false,
+ },
+ // {"path", proto::URL_PATTERN_TYPE_SUBSTRING, kDonotMatchCase}
+ {
+ urlFilter: "path",
+ url: "http://a.com/PaTh",
+ isUrlFilterCaseSensitive: false,
+ expectMatch: true,
+ },
+ // {"path", proto::URL_PATTERN_TYPE_SUBSTRING, kMatchCase}
+ {
+ urlFilter: "path",
+ url: "http://a.com/PaTh",
+ isUrlFilterCaseSensitive: true,
+ expectMatch: false,
+ },
+ // {"path", proto::URL_PATTERN_TYPE_SUBSTRING, kDonotMatchCase}
+ {
+ urlFilter: "path",
+ url: "http://a.com/path",
+ isUrlFilterCaseSensitive: false,
+ expectMatch: true,
+ },
+ // {"path", proto::URL_PATTERN_TYPE_SUBSTRING, kMatchCase}
+ {
+ urlFilter: "path",
+ url: "http://a.com/path",
+ isUrlFilterCaseSensitive: true,
+ expectMatch: true,
+ },
+ // {"abc*def^", proto::URL_PATTERN_TYPE_WILDCARDED, kMatchCase}
+ {
+ urlFilter: "abc*def^",
+ url: "http://a.com/abcxAdef/vo",
+ isUrlFilterCaseSensitive: true,
+ expectMatch: true,
+ },
+ // {"abc*def^", proto::URL_PATTERN_TYPE_WILDCARDED, kMatchCase}
+ {
+ urlFilter: "abc*def^",
+ url: "http://a.com/aBcxAdeF/vo",
+ isUrlFilterCaseSensitive: true,
+ expectMatch: false,
+ },
+ // {"abc*def^", proto::URL_PATTERN_TYPE_WILDCARDED, kDonotMatchCase}
+ {
+ urlFilter: "abc*def^",
+ url: "http://a.com/aBcxAdeF/vo",
+ isUrlFilterCaseSensitive: false,
+ expectMatch: true,
+ },
+ // {"abc*def^", proto::URL_PATTERN_TYPE_WILDCARDED, kDonotMatchCase}
+ {
+ urlFilter: "abc*def^",
+ url: "http://a.com/abcxAdef/vo",
+ isUrlFilterCaseSensitive: false,
+ expectMatch: true,
+ },
+ // {"abc^", kAnchorNone, kAnchorNone}
+ {
+ urlFilter: "abc^",
+ url: "https://xyz.com/abc/123",
+ expectMatch: true,
+ },
+ // {"abc^", kAnchorNone, kAnchorNone}
+ {
+ urlFilter: "abc^",
+ url: "https://xyz.com/abc",
+ expectMatch: true,
+ },
+ // {"abc^", kAnchorNone, kAnchorNone}
+ {
+ urlFilter: "abc^",
+ url: "https://abc.com",
+ expectMatch: false,
+ },
+ // {"abc^", kAnchorNone, kBoundary}
+ {
+ urlFilter: "abc^|",
+ url: "https://xyz.com/abc/",
+ expectMatch: true,
+ },
+ // {"abc^", kAnchorNone, kBoundary}
+ {
+ urlFilter: "abc^|",
+ url: "https://xyz.com/abc",
+ expectMatch: true,
+ },
+ // {"abc^", kAnchorNone, kBoundary}
+ {
+ urlFilter: "abc^|",
+ url: "https://xyz.com/abc/123",
+ expectMatch: false,
+ },
+ // {"http://abc.com/x^", kBoundary, kAnchorNone}
+ {
+ urlFilter: "|http://abc.com/x^",
+ url: "http://abc.com/x",
+ expectMatch: true,
+ },
+ // {"http://abc.com/x^", kBoundary, kAnchorNone}
+ {
+ urlFilter: "|http://abc.com/x^",
+ url: "http://abc.com/x/",
+ expectMatch: true,
+ },
+ // {"http://abc.com/x^", kBoundary, kAnchorNone}
+ {
+ urlFilter: "|http://abc.com/x^",
+ url: "http://abc.com/x/123",
+ expectMatch: true,
+ },
+ // {"http://abc.com/x^", kBoundary, kBoundary}
+ {
+ urlFilter: "|http://abc.com/x^|",
+ url: "http://abc.com/x",
+ expectMatch: true,
+ },
+ // {"http://abc.com/x^", kBoundary, kBoundary}
+ {
+ urlFilter: "|http://abc.com/x^|",
+ url: "http://abc.com/x/",
+ expectMatch: true,
+ },
+ // {"http://abc.com/x^", kBoundary, kBoundary}
+ {
+ urlFilter: "|http://abc.com/x^|",
+ url: "http://abc.com/x/123",
+ expectMatch: false,
+ },
+ // {"abc.com^", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||abc.com^",
+ url: "http://xyz.abc.com/123",
+ expectMatch: true,
+ },
+ // {"abc.com^", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||abc.com^",
+ url: "http://xyz.abc.com",
+ expectMatch: true,
+ },
+ // {"abc.com^", kSubdomain, kAnchorNone}
+ {
+ urlFilter: "||abc.com^",
+ url: "http://abc.com.xyz.com?q=abc.com",
+ expectMatch: false,
+ },
+ // {"abc.com^", kSubdomain, kBoundary}
+ {
+ urlFilter: "||abc.com^|",
+ url: "http://xyz.abc.com/123",
+ expectMatch: false,
+ },
+ // {"abc.com^", kSubdomain, kBoundary}
+ {
+ urlFilter: "||abc.com^|",
+ url: "http://xyz.abc.com",
+ expectMatch: true,
+ },
+ // {"abc.com^", kSubdomain, kBoundary}
+ {
+ urlFilter: "||abc.com^|",
+ url: "http://abc.com.xyz.com?q=abc.com/",
+ expectMatch: false,
+ },
+ // {"abc*^", kAnchorNone, kAnchorNone}
+ {
+ urlFilter: "abc*^",
+ url: "https://abc.com",
+ expectMatch: true,
+ },
+ // {"abc*^", kAnchorNone, kAnchorNone}
+ {
+ urlFilter: "abc*^",
+ url: "https://abc.com?q=123",
+ expectMatch: true,
+ },
+ // {"abc*^", kAnchorNone, kBoundary}
+ {
+ urlFilter: "abc*^|",
+ url: "https://abc.com",
+ expectMatch: true,
+ },
+ // {"abc*^", kAnchorNone, kBoundary}
+ {
+ urlFilter: "abc*^|",
+ url: "https://abc.com?q=123",
+ expectMatch: true,
+ },
+ // {"abc*", kAnchorNone, kBoundary}
+ {
+ urlFilter: "abc*|",
+ url: "https://a.com/abcxyz",
+ expectMatch: true,
+ },
+ // {"*google.com", kBoundary, kAnchorNone}
+ {
+ urlFilter: "|*google.com",
+ url: "https://www.google.com",
+ expectMatch: true,
+ },
+ // {"*", kBoundary, kBoundary}
+ {
+ urlFilter: "|*|",
+ url: "https://example.com",
+ expectMatch: true,
+ },
+ // // {"", kBoundary, kBoundary}
+ // { // "||" is ambiguous, cannot mean Left anchor + Right anchor
+ // urlFilter: "||",
+ // url: "https://example.com",
+ // expectMatch: false,
+ // },
+ ];
+ for (let test of testCases) {
+ let { urlFilter, url, expectMatch, isUrlFilterCaseSensitive } = test;
+ if (expectMatch) {
+ await testMatchesUrlFilter({
+ urlFilter,
+ isUrlFilterCaseSensitive,
+ urls: [url],
+ });
+ } else {
+ await testMatchesUrlFilter({
+ urlFilter,
+ isUrlFilterCaseSensitive,
+ urlsNonMatching: [url],
+ });
+ }
+ }
+
+ browser.test.notifyPass();
+ },
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_webrequest.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_webrequest.js
new file mode 100644
index 0000000000..415ab42c5f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_webrequest.js
@@ -0,0 +1,296 @@
+"use strict";
+
+add_setup(() => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+});
+
+const server = createHttpServer({
+ hosts: ["example.com", "redir"],
+});
+server.registerPathHandler("/never_reached", (req, res) => {
+ Assert.ok(false, "Server should never have been reached");
+});
+server.registerPathHandler("/source", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+});
+server.registerPathHandler("/destination", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+});
+
+add_task(async function block_request_with_dnr() {
+ async function background() {
+ let onBeforeRequestPromise = new Promise(resolve => {
+ browser.webRequest.onBeforeRequest.addListener(resolve, {
+ urls: ["*://example.com/*"],
+ });
+ });
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { requestDomains: ["example.com"] },
+ action: { type: "block" },
+ },
+ ],
+ });
+
+ await browser.test.assertRejects(
+ fetch("http://example.com/never_reached"),
+ "NetworkError when attempting to fetch resource.",
+ "blocked by DNR rule"
+ );
+ // DNR is documented to take precedence over webRequest. We should still
+ // receive the webRequest event, however.
+ browser.test.log("Waiting for webRequest.onBeforeRequest...");
+ await onBeforeRequestPromise;
+ browser.test.log("Seen webRequest.onBeforeRequest!");
+
+ browser.test.notifyPass();
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ host_permissions: ["*://example.com/*"],
+ permissions: ["declarativeNetRequest", "webRequest"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function upgradeScheme_and_redirect_request_with_dnr() {
+ async function background() {
+ let onBeforeRequestSeen = [];
+ browser.webRequest.onBeforeRequest.addListener(
+ d => {
+ onBeforeRequestSeen.push(d.url);
+ // webRequest cancels, but DNR should actually be taking precedence.
+ return { cancel: true };
+ },
+ { urls: ["*://example.com/*", "http://redir/here"] },
+ ["blocking"]
+ );
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { requestDomains: ["example.com"] },
+ action: { type: "upgradeScheme" },
+ },
+ {
+ id: 2,
+ condition: { requestDomains: ["example.com"], urlFilter: "|https:*" },
+ action: { type: "redirect", redirect: { url: "http://redir/here" } },
+ // The upgradeScheme and redirect actions have equal precedence. To
+ // make sure that the redirect action is executed when both rules
+ // match, we assign a higher priority to the redirect action.
+ priority: 2,
+ },
+ ],
+ });
+
+ await browser.test.assertRejects(
+ fetch("http://example.com/never_reached"),
+ "NetworkError when attempting to fetch resource.",
+ "although initially redirected by DNR, ultimately blocked by webRequest"
+ );
+ // DNR is documented to take precedence over webRequest.
+ // So we should actually see redirects according to the DNR rules, and
+ // the webRequest listener should still be able to observe all requests.
+ browser.test.assertDeepEq(
+ [
+ "http://example.com/never_reached",
+ "https://example.com/never_reached",
+ "http://redir/here",
+ ],
+ onBeforeRequestSeen,
+ "Expected onBeforeRequest events"
+ );
+
+ browser.test.notifyPass();
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ host_permissions: ["*://example.com/*", "*://redir/*"],
+ permissions: [
+ "declarativeNetRequest",
+ "webRequest",
+ "webRequestBlocking",
+ ],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function block_request_with_webRequest_after_allow_with_dnr() {
+ async function background() {
+ let onBeforeRequestSeen = [];
+ browser.webRequest.onBeforeRequest.addListener(
+ d => {
+ onBeforeRequestSeen.push(d.url);
+ return { cancel: !d.url.includes("webRequestNoCancel") };
+ },
+ { urls: ["*://example.com/*"] },
+ ["blocking"]
+ );
+ // All DNR actions that do not end up canceling/redirecting the request:
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { requestMethods: ["get"] },
+ action: { type: "allow" },
+ },
+ {
+ id: 2,
+ condition: { requestMethods: ["put"] },
+ action: {
+ type: "modifyHeaders",
+ requestHeaders: [{ operation: "set", header: "x", value: "y" }],
+ },
+ },
+ ],
+ });
+
+ await browser.test.assertRejects(
+ fetch("http://example.com/never_reached?1", { method: "get" }),
+ "NetworkError when attempting to fetch resource.",
+ "despite DNR 'allow' rule, still blocked by webRequest"
+ );
+ await browser.test.assertRejects(
+ fetch("http://example.com/never_reached?2", { method: "put" }),
+ "NetworkError when attempting to fetch resource.",
+ "despite DNR 'modifyHeaders' rule, still blocked by webRequest"
+ );
+ // Just to rule out the request having been canceled by DNR instead of
+ // webRequest, repeat the requests and verify that they succeed.
+ await fetch("http://example.com/?webRequestNoCancel1", { method: "get" });
+ await fetch("http://example.com/?webRequestNoCancel2", { method: "put" });
+
+ browser.test.assertDeepEq(
+ [
+ "http://example.com/never_reached?1",
+ "http://example.com/never_reached?2",
+ "http://example.com/?webRequestNoCancel1",
+ "http://example.com/?webRequestNoCancel2",
+ ],
+ onBeforeRequestSeen,
+ "Expected onBeforeRequest events"
+ );
+
+ browser.test.notifyPass();
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ host_permissions: ["*://example.com/*"],
+ permissions: [
+ "declarativeNetRequest",
+ "webRequest",
+ "webRequestBlocking",
+ ],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function redirect_with_webRequest_after_failing_dnr_redirect() {
+ async function background() {
+ // Maximum length of a UTL is 1048576 (network.standard-url.max-length).
+ const network_standard_url_max_length = 1048576;
+ // updateSessionRules does some validation on the limit (as seen by
+ // validate_action_redirect_transform in test_ext_dnr_session_rules.js),
+ // but it is still possible to pass validation and fail in practice when
+ // the existing URL + new component exceeds the limit.
+ const VERY_LONG_STRING = "x".repeat(network_standard_url_max_length - 20);
+
+ browser.webRequest.onBeforeRequest.addListener(
+ d => {
+ return { redirectUrl: "http://redir/destination?by-webrequest" };
+ },
+ { urls: ["*://example.com/*"] },
+ ["blocking"]
+ );
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { requestDomains: ["example.com"] },
+ action: {
+ type: "redirect",
+ redirect: {
+ transform: {
+ host: "redir",
+ path: "/destination",
+ queryTransform: {
+ addOrReplaceParams: [
+ { key: "dnr", value: VERY_LONG_STRING, replaceOnly: true },
+ ],
+ },
+ },
+ },
+ },
+ },
+ ],
+ });
+
+ // Note: we are not expecting successful DNR redirects below, but in case
+ // that ever changes (e.g. due to VERY_LONG_STRING not resulting in an
+ // invalid URL), we will truncate the URL out of caution.
+ // VERY_LONG_STRING consists of many 'X'. Shorten to avoid logspam.
+ const shortx = s => s.replace(/x{10,}/g, xxx => `x{${xxx.length}}`);
+
+ browser.test.assertEq(
+ "http://redir/destination?1",
+ shortx((await fetch("http://example.com/never_reached?1")).url),
+ "Successful DNR redirect."
+ );
+
+ // DNR redirect failure is expected to be very rare, and only to occur when
+ // an extension intentionally explores the boundaries of the DNR API. When
+ // DNR fails, we fall back to allowing webRequest to take over.
+ browser.test.assertEq(
+ "http://redir/destination?by-webrequest",
+ shortx((await fetch("http://example.com/source?dnr")).url),
+ "When DNR fails, we fall back to webRequest redirect"
+ );
+
+ browser.test.notifyPass();
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ host_permissions: ["*://example.com/*"],
+ permissions: [
+ "declarativeNetRequest",
+ "webRequest",
+ "webRequestBlocking",
+ ],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_without_webrequest.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_without_webrequest.js
new file mode 100644
index 0000000000..5a69b255d2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_without_webrequest.js
@@ -0,0 +1,877 @@
+"use strict";
+
+// This test file verifies that the declarativeNetRequest API can modify
+// network requests as expected without the presence of the webRequest API. See
+// test_ext_dnr_webRequest.js for the interaction between webRequest and DNR.
+
+add_setup(() => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+});
+
+const server = createHttpServer({
+ hosts: ["example.com", "example.net", "example.org", "redir", "dummy"],
+});
+server.registerPathHandler("/cors_202", (req, res) => {
+ res.setStatusLine(req.httpVersion, 202, "Accepted");
+ // The extensions in this test have minimal permissions, so grant CORS to
+ // allow them to read the response without host permissions.
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.setHeader("Access-Control-Max-Age", "0");
+ res.write("cors_response");
+});
+server.registerPathHandler("/never_reached", (req, res) => {
+ Assert.ok(false, "Server should never have been reached");
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.setHeader("Access-Control-Max-Age", "0");
+});
+let gPreflightCount = 0;
+server.registerPathHandler("/preflight_count", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.setHeader("Access-Control-Max-Age", "0");
+ res.setHeader("Access-Control-Allow-Methods", "NONSIMPLE");
+ if (req.method === "OPTIONS") {
+ ++gPreflightCount;
+ } else {
+ // CORS Preflight considers 2xx to be successful. To rule out inadvertent
+ // server opt-in to CORS, respond with a non-2xx response.
+ res.setStatusLine(req.httpVersion, 418, "I'm a teapot");
+ res.write(`count=${gPreflightCount}`);
+ }
+});
+server.registerPathHandler("/", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.setHeader("Access-Control-Max-Age", "0");
+ res.write("Dummy page");
+});
+
+async function contentFetch(initiatorURL, url, options) {
+ let contentPage = await ExtensionTestUtils.loadContentPage(initiatorURL);
+ // Sanity check: that the initiator is as specified, and not redirected.
+ Assert.equal(
+ await contentPage.spawn([], () => content.document.URL),
+ initiatorURL,
+ `Expected document load at: ${initiatorURL}`
+ );
+ let result = await contentPage.spawn([{ url, options }], async args => {
+ try {
+ let req = await content.fetch(args.url, args.options);
+ return {
+ status: req.status,
+ url: req.url,
+ body: await req.text(),
+ };
+ } catch (e) {
+ return { error: e.message };
+ }
+ });
+ await contentPage.close();
+ return result;
+}
+
+async function checkCanFetchFromOtherExtension() {
+ let otherExtension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let req = await fetch("http://example.com/cors_202", { method: "get" });
+ browser.test.assertEq(202, req.status, "not blocked by other extension");
+ browser.test.assertEq("cors_response", await req.text());
+ browser.test.sendMessage("other_extension_done");
+ },
+ });
+ await otherExtension.startup();
+ await otherExtension.awaitMessage("other_extension_done");
+ await otherExtension.unload();
+}
+
+add_task(async function block_request_with_dnr() {
+ async function background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { requestMethods: ["get"] },
+ action: { type: "block" },
+ },
+ {
+ id: 2,
+ condition: { requestMethods: ["head"] },
+ action: { type: "allow" },
+ },
+ ],
+ });
+ {
+ // Request not matching DNR.
+ let req = await fetch("http://example.com/cors_202", { method: "post" });
+ browser.test.assertEq(202, req.status, "allowed without DNR rule");
+ browser.test.assertEq("cors_response", await req.text());
+ }
+ {
+ // Request with "allow" DNR action.
+ let req = await fetch("http://example.com/cors_202", { method: "head" });
+ browser.test.assertEq(202, req.status, "allowed by DNR rule");
+ browser.test.assertEq("", await req.text(), "no response for HEAD");
+ }
+
+ // Request with "block" DNR action.
+ await browser.test.assertRejects(
+ fetch("http://example.com/never_reached", { method: "get" }),
+ "NetworkError when attempting to fetch resource.",
+ "blocked by DNR rule"
+ );
+
+ browser.test.sendMessage("tested_dnr_block");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ allowInsecureRequests: true,
+ background,
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("tested_dnr_block");
+
+ // DNR should not only work with requests within the extension, but also from
+ // web pages.
+ Assert.deepEqual(
+ await contentFetch("http://dummy/", "http://example.com/never_reached"),
+ { error: "NetworkError when attempting to fetch resource." },
+ "Blocked by DNR with declarativeNetRequestWithHostAccess"
+ );
+
+ // declarativeNetRequest does not allow extensions to block requests from
+ // other extensions.
+ await checkCanFetchFromOtherExtension();
+
+ // Except when the user opts in via a preference. When the pref is on, then:
+ // The declarativeNetRequest permission grants the ability to block requests
+ // from other extensions. (The declarativeNetRequestWithHostAccess permission
+ // does not; see test task block_with_declarativeNetRequestWithHostAccess.)
+ let otherExtension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.test.assertRejects(
+ fetch("http://example.com/never_reached", { method: "get" }),
+ "NetworkError when attempting to fetch resource.",
+ "blocked by different extension with declarativeNetRequest permission"
+ );
+ browser.test.sendMessage("other_extension_done");
+ },
+ });
+ await runWithPrefs(
+ [["extensions.dnr.match_requests_from_other_extensions", true]],
+ async () => {
+ info("Verifying that fetch() from extension is intercepted with pref");
+ await otherExtension.startup();
+ await otherExtension.awaitMessage("other_extension_done");
+ await otherExtension.unload();
+ }
+ );
+
+ await extension.unload();
+});
+
+// Verifies that the "declarativeNetRequestWithHostAccess" permission can only
+// block if it has permission for the initiator.
+add_task(async function block_with_declarativeNetRequestWithHostAccess() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [{ id: 1, condition: {}, action: { type: "block" } }],
+ });
+ browser.test.sendMessage("dnr_registered");
+ },
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ host_permissions: ["<all_urls>"],
+ permissions: ["declarativeNetRequestWithHostAccess"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("dnr_registered");
+
+ // Initiator "http://dummy" does match "<all_urls>", so DNR rule should apply.
+ Assert.deepEqual(
+ await contentFetch("http://dummy/", "http://example.com/never_reached"),
+ { error: "NetworkError when attempting to fetch resource." },
+ "Blocked by DNR with declarativeNetRequestWithHostAccess"
+ );
+
+ // Extensions cannot have permissions for another extension and therefore the
+ // DNR rule never applies.
+ await checkCanFetchFromOtherExtension();
+
+ // Sanity check: even with the pref, the declarativeNetRequestWithHostAccess
+ // permission should not grant access.
+ info("Verifying that access is not allowed, despite the pref being true");
+ await runWithPrefs(
+ [["extensions.dnr.match_requests_from_other_extensions", true]],
+ checkCanFetchFromOtherExtension
+ );
+
+ await extension.unload();
+});
+
+add_task(async function block_in_sandboxed_extension_page() {
+ const filesWithSandbox = {
+ "page_with_sandbox.html": `<!DOCTYPE html><meta charset="utf-8">
+ <script src="page_with_sandbox.js"></script>
+ <iframe src="sandbox.html" sandbox="allow-scripts"></iframe>
+ `,
+ "page_with_sandbox.js": () => {
+ // Sent by sandbox.js:
+ window.onmessage = e => {
+ browser.test.assertEq("null", e.origin, "Sender has opaque origin");
+ browser.test.sendMessage("fetch_result", e.data);
+ };
+ },
+ "sandbox.html": `<script src="sandbox.js"></script>`,
+ "sandbox.js": async () => {
+ try {
+ // Note that the test server responds with CORS headers, so we should
+ // be able to fetch this URL:
+ await fetch("http://example.com/?fetch_by_sandbox");
+ parent.postMessage("FETCH_ALLOWED", "*");
+ } catch (e) {
+ // The only way for this to fail in this test is when DNR blocks it.
+ parent.postMessage("FETCH_BLOCKED", "*");
+ }
+ },
+ };
+ async function checkFetchInSandboxedExtensionPage(ext) {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${ext.uuid}/page_with_sandbox.html`
+ );
+ let result = await ext.awaitMessage("fetch_result");
+ await contentPage.close();
+ return result;
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [{ id: 1, condition: {}, action: { type: "block" } }],
+ });
+ browser.test.sendMessage("dnr_registered");
+ },
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest"],
+ },
+ files: filesWithSandbox,
+ });
+ await extension.startup();
+ await extension.awaitMessage("dnr_registered");
+
+ Assert.equal(
+ await checkFetchInSandboxedExtensionPage(extension),
+ "FETCH_BLOCKED",
+ "DNR blocks request from sandboxed page in own extension"
+ );
+
+ let otherExtension = ExtensionTestUtils.loadExtension({
+ files: filesWithSandbox,
+ });
+ await otherExtension.startup();
+
+ // Note: In Firefox, webRequest can intercept requests from opaque origins
+ // opened by other extensions. In contrast, that is not the case in Chrome.
+ Assert.equal(
+ await checkFetchInSandboxedExtensionPage(otherExtension),
+ "FETCH_BLOCKED",
+ "DNR can block request from sandboxed page in other extension"
+ );
+
+ await runWithPrefs(
+ [["extensions.dnr.match_requests_from_other_extensions", true]],
+ async () => {
+ info("Verifying that fetch() from extension sandbox is matched via pref");
+ Assert.equal(
+ await checkFetchInSandboxedExtensionPage(otherExtension),
+ "FETCH_BLOCKED",
+ "DNR can block request from sandboxed page in other extension via pref"
+ );
+ }
+ );
+ await extension.unload();
+
+ // As a sanity check, to verify that the tests above do not always return
+ // FETCH_BLOCKED, run a test case that returns FETCH_ALLOWED:
+ Assert.equal(
+ await checkFetchInSandboxedExtensionPage(otherExtension),
+ "FETCH_ALLOWED",
+ "DNR does not affect sandboxed extensions after unloading the DNR extension"
+ );
+
+ await otherExtension.unload();
+});
+
+// Verifies that upgradeScheme works.
+// The HttpServer helper does not support https (bug 1742061), so in this
+// test we just verify whether the upgrade has been attempted. Coverage that
+// verifies that the upgraded request completes is in:
+// toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html
+add_task(async function upgradeScheme_declarativeNetRequestWithHostAccess() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { excludedRequestDomains: ["dummy"] },
+ action: { type: "upgradeScheme" },
+ },
+ {
+ id: 2,
+ // HttpServer does not support https (bug 1742061).
+ // As a work-around, we just redirect the https:-request to http.
+ condition: { urlFilter: "|https:*" },
+ action: {
+ type: "redirect",
+ redirect: { url: "http://dummy/cors_202?from_https" },
+ },
+ // The upgradeScheme and redirect actions have equal precedence. To
+ // make sure that the redirect action is executed when both rules
+ // match, we assign a higher priority to the redirect action.
+ priority: 2,
+ },
+ ],
+ });
+
+ let req = await fetch("http://redir/never_reached");
+ browser.test.assertEq(
+ "http://dummy/cors_202?from_https",
+ req.url,
+ "upgradeScheme upgraded to https"
+ );
+ browser.test.assertEq("cors_response", await req.text());
+
+ browser.test.sendMessage("tested_dnr_upgradeScheme");
+ },
+ temporarilyInstalled: true, // Needed for granted_host_permissions.
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ host_permissions: ["*://dummy/*", "*://redir/*"],
+ permissions: ["declarativeNetRequestWithHostAccess"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("tested_dnr_upgradeScheme");
+
+ // Request to same-origin subresource, which should be upgraded.
+ Assert.equal(
+ (await contentFetch("http://redir/", "http://redir/never_reached")).url,
+ "http://dummy/cors_202?from_https",
+ "upgradeScheme + host access should upgrade (same-origin request)"
+ );
+
+ // Request to cross-origin subresource, which should be upgraded.
+ // Note: after the upgrade, a cross-origin redirect happens. Internally, we
+ // reflect the Origin request header in the Access-Control-Allow-Origin (ACAO)
+ // response header, to ensure that the request is accepted by CORS. See
+ // https://github.com/w3c/webappsec-upgrade-insecure-requests/issues/32
+ Assert.equal(
+ (await contentFetch("http://dummy/", "http://redir/never_reached")).url,
+ "http://dummy/cors_202?from_https",
+ "upgradeScheme + host access should upgrade (cross-origin request)"
+ );
+
+ // The DNR extension does not have example.net in host_permissions.
+ const urlNoHostPerms = "http://example.net/cors_202?missing_host_permission";
+ Assert.equal(
+ (await contentFetch("http://dummy/", urlNoHostPerms)).url,
+ urlNoHostPerms,
+ "upgradeScheme not matched when extension lacks host access"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function redirect_request_with_dnr() {
+ async function background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: {
+ requestDomains: ["example.com"],
+ requestMethods: ["get"],
+ },
+ action: {
+ type: "redirect",
+ redirect: {
+ url: "http://example.net/cors_202?1",
+ },
+ },
+ },
+ {
+ id: 2,
+ // Note: extension does not have example.org host permission.
+ condition: { requestDomains: ["example.org"] },
+ action: {
+ type: "redirect",
+ redirect: {
+ url: "http://example.net/cors_202?2",
+ },
+ },
+ },
+ ],
+ });
+ // The extension only has example.com permission, but the redirects to
+ // example.net are still due to the CORS headers from the server.
+ {
+ // Simple GET request.
+ let req = await fetch("http://example.com/never_reached");
+ browser.test.assertEq(202, req.status, "redirected by DNR (simple)");
+ browser.test.assertEq("http://example.net/cors_202?1", req.url);
+ browser.test.assertEq("cors_response", await req.text());
+ }
+ {
+ // GeT request should be matched despite having a different case.
+ let req = await fetch("http://example.com/never_reached", {
+ method: "GeT",
+ });
+ browser.test.assertEq(202, req.status, "redirected by DNR (GeT)");
+ browser.test.assertEq("http://example.net/cors_202?1", req.url);
+ browser.test.assertEq("cors_response", await req.text());
+ }
+ {
+ // Host permission missing for request, request not redirected by DNR.
+ // Response is readable due to the CORS response headers from the server.
+ let req = await fetch("http://example.org/cors_202?noredir");
+ browser.test.assertEq(202, req.status, "not redirected by DNR");
+ browser.test.assertEq("http://example.org/cors_202?noredir", req.url);
+ browser.test.assertEq("cors_response", await req.text());
+ }
+
+ browser.test.notifyPass();
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ host_permissions: ["*://example.com/*"],
+ permissions: ["declarativeNetRequest"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+
+ let otherExtension = ExtensionTestUtils.loadExtension({
+ async background() {
+ // The DNR extension has permissions for example.com, but not for this
+ // extension. Therefore the "redirect" action should not apply.
+ let req = await fetch("http://example.com/cors_202?other_ext");
+ browser.test.assertEq(202, req.status, "not redirected by DNR");
+ browser.test.assertEq("http://example.com/cors_202?other_ext", req.url);
+ browser.test.assertEq("cors_response", await req.text());
+ browser.test.sendMessage("other_extension_done");
+ },
+ });
+ await otherExtension.startup();
+ await otherExtension.awaitMessage("other_extension_done");
+ await otherExtension.unload();
+
+ await extension.unload();
+});
+
+// Verifies that DNR redirects requiring a CORS preflight behave as expected.
+add_task(async function redirect_request_with_dnr_cors_preflight() {
+ // Most other test tasks only test requests within the test extension. This
+ // test intentionally triggers requests outside the extension, to make sure
+ // that the usual CORS mechanisms is triggered (instead of exceptions from
+ // host permissions).
+ async function background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: {
+ requestDomains: ["redir"],
+ excludedRequestMethods: ["options"],
+ },
+ action: {
+ type: "redirect",
+ redirect: {
+ url: "http://example.com/preflight_count",
+ },
+ },
+ },
+ {
+ id: 2,
+ condition: {
+ requestDomains: ["example.net"],
+ excludedRequestMethods: ["nonsimple"], // note: redirects "options"
+ },
+ action: {
+ type: "redirect",
+ redirect: {
+ url: "http://example.com/preflight_count",
+ },
+ },
+ },
+ ],
+ });
+ let req = await fetch("http://redir/never_reached", {
+ method: "NONSIMPLE",
+ });
+ // Extension has permission for "redir", but not for the redirect target.
+ // The request is non-simple (see below for explanation of non-simple), so
+ // a preflight (OPTIONS) request to /preflight_count is expected before the
+ // redirection target is requested.
+ browser.test.assertEq(
+ "count=1",
+ await req.text(),
+ "Got preflight before redirect target because of missing host_permissions"
+ );
+
+ browser.test.sendMessage("continue_preflight_tests");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ // "redir" and "example.net" are needed to allow redirection of these.
+ // "dummy" is needed to redirect requests initiated from http://dummy.
+ host_permissions: ["*://redir/*", "*://example.net/*", "*://dummy/*"],
+ permissions: ["declarativeNetRequest"],
+ },
+ });
+ gPreflightCount = 0;
+ await extension.startup();
+ await extension.awaitMessage("continue_preflight_tests");
+ gPreflightCount = 0; // value already checked before continue_preflight_tests.
+
+ // Simple request (i.e. without preflight requirement), that's redirected to
+ // another URL by the DNR rule. The redirect should be accepted, and in
+ // particular not be blocked by the same-origin policy. The redirect target
+ // (/preflight_count) is readable due to the CORS headers from the server.
+ Assert.deepEqual(
+ await contentFetch("http://dummy/", "http://redir/never_reached"),
+ // count=0: A simple request does not trigger a preflight (OPTIONS) request.
+ { status: 418, url: "http://example.com/preflight_count", body: "count=0" },
+ "Simple request should not have a preflight."
+ );
+
+ // Any request method other than "GET", "POST" and "PUT" (e.g "NONSIMPLE") is
+ // a non-simple request that triggers a preflight request ("OPTIONS").
+ //
+ // Usually, this happens (without extension-triggered redirects):
+ // 1. NONSIMPLE /never_reached : is started, but does NOT hit the server yet.
+ // 2. OPTIONS /never_reached + Access-Control-Request-Method: NONSIMPLE
+ // 3. NONSIMPLE /never_reached : reaches the server if allowed by OPTIONS.
+ //
+ // With an extension-initiated redirect to /preflight_count:
+ // 1. NONSIMPLE /never_reached : is started, but does not hit the server yet.
+ // 2. extension redirects to /preflight_count
+ // 3. OPTIONS /preflight_count + Access-Control-Request-Method: NONSIMPLE
+ // - This is because the redirect preserves the request method/body/etc.
+ // 4. NONSIMPLE /preflight_count : reaches the server if allowed by OPTIONS.
+ Assert.deepEqual(
+ await contentFetch("http://dummy/", "http://redir/never_reached", {
+ method: "NONSIMPLE",
+ }),
+ // Due to excludedRequestMethods: ["options"], the preflight for the
+ // redirect target is not intercepted, so the server sees a preflight.
+ { status: 418, url: "http://example.com/preflight_count", body: "count=1" },
+ "Initial URL redirected, redirection target has preflight"
+ );
+ gPreflightCount = 0;
+
+ // The "example.net" rule has "excludedRequestMethods": ["nonsimple"], so the
+ // initial "NONSIMPLE" request is not immediately redirected. Therefore the
+ // preflight request happens. This OPTIONS request is matched by the DNR rule
+ // and redirected to /preflight_count. While preflight_count offers a very
+ // permissive preflight response, it is not even fetched:
+ // Only a 2xx HTTP status is considered a valid response to a pre-flight.
+ // A redirect is like a 3xx HTTP status, so the whole request is rejected,
+ // and the redirect is not followed for the OPTIONS request.
+ Assert.deepEqual(
+ await contentFetch("http://dummy/", "http://example.net/never_reached", {
+ method: "NONSIMPLE",
+ }),
+ { error: "NetworkError when attempting to fetch resource." },
+ "Redirect of preflight request (OPTIONS) should be a CORS failure"
+ );
+
+ Assert.equal(gPreflightCount, 0, "Preflight OPTIONS has been intercepted");
+
+ await extension.unload();
+});
+
+// Tests that DNR redirect rules can be chained.
+add_task(async function redirect_request_with_dnr_multiple_hops() {
+ async function background() {
+ // Set up redirects from example.com up until dummy.
+ let hosts = ["example.com", "example.net", "example.org", "redir", "dummy"];
+ let rules = [];
+ for (let i = 1; i < hosts.length; ++i) {
+ const from = hosts[i - 1];
+ const to = hosts[i];
+ const end = hosts.length - 1 === i;
+ rules.push({
+ id: i,
+ condition: { requestDomains: [from] },
+ action: {
+ type: "redirect",
+ redirect: {
+ // All intermediate redirects should never hit the server, but the
+ // last one should..
+ url: end ? `http://${to}/?end` : `http://${to}/never_reached`,
+ },
+ },
+ });
+ }
+ await browser.declarativeNetRequest.updateSessionRules({ addRules: rules });
+ let req = await fetch("http://example.com/never_reached");
+ browser.test.assertEq(200, req.status, "redirected by DNR (multiple)");
+ browser.test.assertEq("http://dummy/?end", req.url, "Last URL in chain");
+ browser.test.assertEq("Dummy page", await req.text());
+
+ browser.test.notifyPass();
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ host_permissions: ["*://*/*"], // matches all in the |hosts| list.
+ permissions: ["declarativeNetRequest"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+
+ // Test again, but without special extension permissions to verify that DNR
+ // redirects pass CORS checks.
+ Assert.deepEqual(
+ await contentFetch("http://dummy/", "http://redir/never_reached"),
+ { status: 200, url: "http://dummy/?end", body: "Dummy page" },
+ "Multiple redirects by DNR, requested from web origin."
+ );
+
+ await extension.unload();
+});
+
+add_task(async function redirect_request_with_dnr_with_redirect_loop() {
+ async function background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ // requestMethods is mutually exclusive with the other rule.
+ condition: { regexFilter: "^(.+)$", requestMethods: ["post"] },
+ action: {
+ type: "redirect",
+ redirect: {
+ // Appends "?loop" to the request URL
+ regexSubstitution: "\\1?loop",
+ },
+ },
+ },
+ {
+ id: 2,
+ // requestMethods is mutually exclusive with the other rule.
+ condition: { requestDomains: ["redir"], requestMethods: ["get"] },
+ action: {
+ type: "redirect",
+ redirect: {
+ // Despite redirect.url matching the condition, the redirect loop
+ // should be caught because of the obvious fact that the URL did
+ // not change.
+ url: "http://redir/cors_202?loop",
+ },
+ },
+ },
+ ],
+ });
+
+ // Redirect where the redirect URL changes at every redirect.
+ await browser.test.assertRejects(
+ fetch("http://redir/cors_202?loop", { method: "post" }),
+ "NetworkError when attempting to fetch resource.",
+ "Redirect loop caught (redirect target differs at every redirect)"
+ );
+
+ async function assertRedirect(url, expected, description) {
+ // method: "get" could only match rule 2.
+ let res = await fetch(url);
+ browser.test.assertDeepEq(
+ expected,
+ { status: res.status, url: res.url, redirected: res.redirected },
+ description
+ );
+ }
+
+ // Redirect with initially a different URL.
+ await assertRedirect(
+ "http://redir/never_reached?",
+ { status: 202, url: "http://redir/cors_202?loop", redirected: true },
+ "Redirect loop caught (initially different URL)"
+ );
+
+ // Redirect where redirect is exactly the same URL as requested.
+ await assertRedirect(
+ "http://redir/cors_202?loop",
+ { status: 202, url: "http://redir/cors_202?loop", redirected: false },
+ "Redirect loop caught (redirect target same as initial URL)"
+ );
+
+ browser.test.notifyPass();
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ host_permissions: ["*://redir/*"],
+ permissions: ["declarativeNetRequest"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+// Tests that redirect to extensionPath works, provided that the initiator is
+// either the extension itself, or in host_permissions. Moreover, the requested
+// resource must match a web_accessible_resources entry for both the initiator
+// AND the pre-redirect URL.
+add_task(async function redirect_request_with_dnr_to_extensionPath() {
+ async function background() {
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: [
+ {
+ id: 1,
+ condition: { requestDomains: ["redir"], requestMethods: ["post"] },
+ action: {
+ type: "redirect",
+ redirect: {
+ extensionPath: "/war.txt?1",
+ },
+ },
+ },
+ {
+ id: 2,
+ condition: { requestDomains: ["redir"], requestMethods: ["put"] },
+ action: {
+ type: "redirect",
+ redirect: {
+ extensionPath: "/nonwar.txt?2",
+ },
+ },
+ },
+ ],
+ });
+ {
+ let req = await fetch("http://redir/never_reached", { method: "post" });
+ browser.test.assertEq(200, req.status, "redirected to extensionPath");
+ browser.test.assertEq(`${location.origin}/war.txt?1`, req.url);
+ browser.test.assertEq("war_ext_res", await req.text());
+ }
+ // Redirects to extensionPath that is not in web_accessible_resources.
+ // While the initiator (extension) would be allowed to read the resource
+ // due to it being same-origin, the pre-redirect URL (http://redir) is not
+ // matching web_accessible_resources[].matches, so the load is rejected.
+ //
+ // This behavior differs from Chrome (e.g. at least in Chrome 109) that
+ // does allow the load to complete. Extensions who really care about
+ // exposing a web-accessible resource to the world can just put an all_urls
+ // pattern in web_accessible_resources[].matches.
+ await browser.test.assertRejects(
+ fetch("http://redir/never_reached", { method: "put" }),
+ "NetworkError when attempting to fetch resource.",
+ "Redirect to nowar.txt, but pre-redirect host is not in web_accessible_resources[].matches"
+ );
+
+ browser.test.notifyPass();
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ granted_host_permissions: true,
+ host_permissions: ["*://redir/*", "*://dummy/*"],
+ permissions: ["declarativeNetRequest"],
+ web_accessible_resources: [
+ // *://redir/* is in matches, because that is the pre-redirect host.
+ // *://dummy/* is in matches, because that is an initiator below.
+ { resources: ["war.txt"], matches: ["*://redir/*", "*://dummy/*"] },
+ // without "matches", this is almost equivalent to not being listed in
+ // web_accessible_resources at all. This entry is listed here to verify
+ // that the presence of extension_ids does not somehow allow a request
+ // with an extension initiator to complete.
+ { resources: ["nonwar.txt"], extension_ids: ["*"] },
+ ],
+ },
+ files: {
+ "war.txt": "war_ext_res",
+ "nonwar.txt": "non_war_ext_res",
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ const extPrefix = `moz-extension://${extension.uuid}`;
+
+ // Request from origin in host_permissions, for web-accessible resource.
+ Assert.deepEqual(
+ await contentFetch(
+ "http://dummy/", // <-- Matching web_accessible_resources[].matches
+ "http://redir/never_reached", // <-- With matching host_permissions
+ { method: "post" }
+ ),
+ { status: 200, url: `${extPrefix}/war.txt?1`, body: "war_ext_res" },
+ "Should have got redirect to web_accessible_resources (war.txt)"
+ );
+
+ // Request from origin in host_permissions, for non-web-accessible resource.
+ let { messages } = await promiseConsoleOutput(async () => {
+ Assert.deepEqual(
+ await contentFetch(
+ "http://dummy/", // <-- Matching web_accessible_resources[].matches
+ "http://redir/never_reached", // <-- With matching host_permissions
+ { method: "put" }
+ ),
+ { error: "NetworkError when attempting to fetch resource." },
+ "Redirect to nowar.txt, without matching web_accessible_resources[].matches"
+ );
+ });
+ const EXPECTED_SECURITY_ERROR = `Content at http://redir/never_reached may not load or link to ${extPrefix}/nonwar.txt?2.`;
+ Assert.equal(
+ messages.filter(m => m.message.includes(EXPECTED_SECURITY_ERROR)).length,
+ 1,
+ `Should log SecurityError: ${EXPECTED_SECURITY_ERROR}`
+ );
+
+ // Request from origin not in host_permissions. DNR rule should not apply.
+ Assert.deepEqual(
+ await contentFetch(
+ "http://dummy/", // <-- Matching web_accessible_resources[].matches
+ "http://example.com/cors_202", // <-- NOT in host_permissions
+ { method: "post" }
+ ),
+ { status: 202, url: "http://example.com/cors_202", body: "cors_response" },
+ "Extension should not have redirected, due to lack of host permissions"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dns.js b/toolkit/components/extensions/test/xpcshell/test_ext_dns.js
new file mode 100644
index 0000000000..56bd3ae174
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dns.js
@@ -0,0 +1,176 @@
+"use strict";
+
+// Some test machines and android are not returning ipv6, turn it
+// off to get consistent test results.
+Services.prefs.setBoolPref("network.dns.disableIPv6", true);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+function getExtension(background = undefined) {
+ let manifest = {
+ permissions: ["dns", "proxy"],
+ };
+ return ExtensionTestUtils.loadExtension({
+ manifest,
+ background() {
+ browser.test.onMessage.addListener(async (msg, data) => {
+ if (msg == "proxy") {
+ await browser.proxy.settings.set({ value: data });
+ browser.test.sendMessage("proxied");
+ return;
+ }
+ browser.test.log(`=== dns resolve test ${JSON.stringify(data)}`);
+ browser.dns
+ .resolve(data.hostname, data.flags)
+ .then(result => {
+ browser.test.log(
+ `=== dns resolve result ${JSON.stringify(result)}`
+ );
+ browser.test.sendMessage("resolved", result);
+ })
+ .catch(e => {
+ browser.test.log(`=== dns resolve error ${e.message}`);
+ browser.test.sendMessage("resolved", { message: e.message });
+ });
+ });
+ browser.test.sendMessage("ready");
+ },
+ incognitoOverride: "spanning",
+ useAddonManager: "temporary",
+ });
+}
+
+const tests = [
+ {
+ request: {
+ hostname: "localhost",
+ },
+ expect: {
+ addresses: ["127.0.0.1"], // ipv6 disabled , "::1"
+ },
+ },
+ {
+ request: {
+ hostname: "localhost",
+ flags: ["offline"],
+ },
+ expect: {
+ addresses: ["127.0.0.1"], // ipv6 disabled , "::1"
+ },
+ },
+ {
+ request: {
+ hostname: "test.example",
+ },
+ expect: {
+ // android will error with offline
+ error: /NS_ERROR_UNKNOWN_HOST|NS_ERROR_OFFLINE/,
+ },
+ },
+ {
+ request: {
+ hostname: "127.0.0.1",
+ flags: ["canonical_name"],
+ },
+ expect: {
+ canonicalName: "127.0.0.1",
+ addresses: ["127.0.0.1"],
+ },
+ },
+ {
+ request: {
+ hostname: "localhost",
+ flags: ["disable_ipv6"],
+ },
+ expect: {
+ addresses: ["127.0.0.1"],
+ },
+ },
+];
+
+add_setup(async function startup() {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_dns_resolve() {
+ let extension = getExtension();
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ for (let test of tests) {
+ extension.sendMessage("resolve", test.request);
+ let result = await extension.awaitMessage("resolved");
+ if (test.expect.error) {
+ ok(
+ test.expect.error.test(result.message),
+ `expected error ${result.message}`
+ );
+ } else {
+ equal(
+ result.canonicalName,
+ test.expect.canonicalName,
+ "canonicalName match"
+ );
+ // It seems there are platform differences happening that make this
+ // testing difficult. We're going to rely on other existing dns tests to validate
+ // the dns service itself works and only validate that we're getting generally
+ // expected results in the webext api.
+ ok(
+ result.addresses.length >= test.expect.addresses.length,
+ "expected number of addresses returned"
+ );
+ if (test.expect.addresses.length && result.addresses.length) {
+ ok(
+ result.addresses.includes(test.expect.addresses[0]),
+ "got expected ip address"
+ );
+ }
+ }
+ }
+
+ await extension.unload();
+});
+
+add_task(async function test_dns_resolve_socks() {
+ let extension = getExtension();
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ extension.sendMessage("proxy", {
+ proxyType: "manual",
+ socks: "127.0.0.1",
+ socksVersion: 5,
+ proxyDNS: true,
+ });
+ await extension.awaitMessage("proxied");
+ equal(
+ Services.prefs.getIntPref("network.proxy.type"),
+ 1 /* PROXYCONFIG_MANUAL */,
+ "manual proxy"
+ );
+ equal(
+ Services.prefs.getStringPref("network.proxy.socks"),
+ "127.0.0.1",
+ "socks proxy"
+ );
+ ok(
+ Services.prefs.getBoolPref("network.proxy.socks_remote_dns"),
+ "socks remote dns"
+ );
+ extension.sendMessage("resolve", {
+ hostname: "mozilla.org",
+ });
+ let result = await extension.awaitMessage("resolved");
+ ok(
+ /NS_ERROR_UNKNOWN_PROXY_HOST/.test(result.message),
+ `expected error ${result.message}`
+ );
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js
new file mode 100644
index 0000000000..f65df707e1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js
@@ -0,0 +1,38 @@
+/* -*- 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_downloads_api_namespace_and_permissions() {
+ function backgroundScript() {
+ browser.test.assertTrue(!!browser.downloads, "`downloads` API is present.");
+ browser.test.assertTrue(
+ !!browser.downloads.FilenameConflictAction,
+ "`downloads.FilenameConflictAction` enum is present."
+ );
+ browser.test.assertTrue(
+ !!browser.downloads.InterruptReason,
+ "`downloads.InterruptReason` enum is present."
+ );
+ browser.test.assertTrue(
+ !!browser.downloads.DangerType,
+ "`downloads.DangerType` enum is present."
+ );
+ browser.test.assertTrue(
+ !!browser.downloads.State,
+ "`downloads.State` enum is present."
+ );
+ browser.test.notifyPass("downloads tests");
+ }
+
+ let extensionData = {
+ background: backgroundScript,
+ manifest: {
+ permissions: ["downloads", "downloads.open"],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitFinish("downloads tests");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js
new file mode 100644
index 0000000000..e79e3adbfb
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js
@@ -0,0 +1,469 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+function cookiesToMime(cookies) {
+ return `dummy/${encodeURIComponent(cookies)}`.toLowerCase();
+}
+
+function mimeToCookies(mime) {
+ return decodeURIComponent(mime.replace("dummy/", ""));
+}
+
+const server = createHttpServer({ hosts: ["example.net"] });
+
+server.registerPathHandler("/download", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ let cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : "";
+ // Assign the result through the MIME-type, to make it easier to read the
+ // result via the downloads API.
+ response.setHeader("Content-Type", cookiesToMime(cookies));
+ // Response of length 7.
+ response.write("1234567");
+});
+
+const DOWNLOAD_URL = "http://example.net/download";
+
+async function setUpCookies() {
+ Services.cookies.removeAll();
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ permissions: ["cookies", "http://example.net/download"],
+ },
+ async background() {
+ let url = "http://example.net/download";
+ // Add default cookie
+ await browser.cookies.set({
+ url,
+ name: "cookie_normal",
+ value: "1",
+ });
+
+ // Add private cookie
+ await browser.cookies.set({
+ url,
+ storeId: "firefox-private",
+ name: "cookie_private",
+ value: "1",
+ });
+
+ // Add container cookie
+ await browser.cookies.set({
+ url,
+ storeId: "firefox-container-1",
+ name: "cookie_container",
+ value: "1",
+ });
+ browser.test.sendMessage("cookies set");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("cookies set");
+ await extension.unload();
+}
+
+function createDownloadTestExtension(extraPermissions = [], incognito = false) {
+ let extensionOptions = {
+ manifest: {
+ permissions: ["downloads", ...extraPermissions],
+ },
+ background() {
+ browser.test.onMessage.addListener(async (method, data) => {
+ async function getDownload(data) {
+ let donePromise = new Promise(resolve => {
+ browser.downloads.onChanged.addListener(async delta => {
+ if (delta.state?.current === "complete") {
+ resolve(delta.id);
+ }
+ });
+ });
+ let downloadId = await browser.downloads.download(data);
+ browser.test.assertEq(await donePromise, downloadId, "got download");
+ let [download] = await browser.downloads.search({ id: downloadId });
+ browser.test.log(`Download results: ${JSON.stringify(download)}`);
+ // Delete the file since we aren't interested in it.
+ // TODO bug 1654819: On Windows the file may be recreated.
+ await browser.downloads.removeFile(download.id);
+ // Sanity check to verify that we got the result from /download.
+ browser.test.assertEq(7, download.fileSize, "download succeeded");
+ return download;
+ }
+ function checkDownloadError(data) {
+ return browser.test.assertRejects(
+ browser.downloads.download(data.downloadData),
+ data.exceptionRe
+ );
+ }
+ function search(data) {
+ return browser.downloads.search(data);
+ }
+ function erase(data) {
+ return browser.downloads.erase(data);
+ }
+ switch (method) {
+ case "getDownload":
+ return browser.test.sendMessage(method, await getDownload(data));
+ case "checkDownloadError":
+ return browser.test.sendMessage(
+ method,
+ await checkDownloadError(data)
+ );
+ case "search":
+ return browser.test.sendMessage(method, await search(data));
+ case "erase":
+ return browser.test.sendMessage(method, await erase(data));
+ }
+ });
+ },
+ };
+ if (incognito) {
+ extensionOptions.incognitoOverride = "spanning";
+ }
+ return ExtensionTestUtils.loadExtension(extensionOptions);
+}
+
+function getResult(extension, method, data) {
+ extension.sendMessage(method, data);
+ return extension.awaitMessage(method);
+}
+
+async function getCookies(extension, data) {
+ let download = await getResult(extension, "getDownload", data);
+ // The "/download" endpoint mirrors received cookies via Content-Type.
+ let cookies = mimeToCookies(download.mime);
+ return cookies;
+}
+
+async function runTests(extension, containerDownloadAllowed, privateAllowed) {
+ let forcedIncognitoException = null;
+ if (!privateAllowed) {
+ forcedIncognitoException = /private browsing access not allowed/;
+ } else if (!containerDownloadAllowed) {
+ forcedIncognitoException = /No permission for cookieStoreId/;
+ }
+
+ // Test default container download
+ if (containerDownloadAllowed) {
+ equal(
+ await getCookies(extension, {
+ url: DOWNLOAD_URL,
+ cookieStoreId: "firefox-default",
+ }),
+ "cookie_normal=1",
+ "Default container cookies for downloads.download"
+ );
+ } else {
+ await getResult(extension, "checkDownloadError", {
+ exceptionRe: /No permission for cookieStoreId/,
+ downloadData: {
+ url: DOWNLOAD_URL,
+ cookieStoreId: "firefox-default",
+ },
+ });
+ }
+
+ // Test private container download
+ if (privateAllowed && containerDownloadAllowed) {
+ equal(
+ await getCookies(extension, {
+ url: DOWNLOAD_URL,
+ cookieStoreId: "firefox-private",
+ incognito: true,
+ }),
+ "cookie_private=1",
+ "Private container cookies for downloads.download"
+ );
+ } else {
+ await getResult(extension, "checkDownloadError", {
+ exceptionRe: forcedIncognitoException,
+ downloadData: {
+ url: DOWNLOAD_URL,
+ cookieStoreId: "firefox-private",
+ incognito: true,
+ },
+ });
+ }
+
+ // Test firefox-container-1 download
+ if (containerDownloadAllowed) {
+ equal(
+ await getCookies(extension, {
+ url: DOWNLOAD_URL,
+ cookieStoreId: "firefox-container-1",
+ }),
+ "cookie_container=1",
+ "firefox-container-1 cookies for downloads.download"
+ );
+ } else {
+ await getResult(extension, "checkDownloadError", {
+ exceptionRe: /No permission for cookieStoreId/,
+ downloadData: {
+ url: DOWNLOAD_URL,
+ cookieStoreId: "firefox-container-1",
+ },
+ });
+ }
+
+ // Test mismatched incognito and cookieStoreId download
+ await getResult(extension, "checkDownloadError", {
+ exceptionRe: forcedIncognitoException
+ ? forcedIncognitoException
+ : /Illegal to set non-private cookieStoreId in a private window/,
+ downloadData: {
+ url: DOWNLOAD_URL,
+ incognito: true,
+ cookieStoreId: "firefox-container-1",
+ },
+ });
+ await getResult(extension, "checkDownloadError", {
+ exceptionRe: containerDownloadAllowed
+ ? /Illegal to set private cookieStoreId in a non-private window/
+ : /No permission for cookieStoreId/,
+ downloadData: {
+ url: DOWNLOAD_URL,
+ incognito: false,
+ cookieStoreId: "firefox-private",
+ },
+ });
+
+ // Test invalid cookieStoreId download
+ await getResult(extension, "checkDownloadError", {
+ exceptionRe: containerDownloadAllowed
+ ? /Illegal cookieStoreId/
+ : /No permission for cookieStoreId/,
+ downloadData: {
+ url: DOWNLOAD_URL,
+ cookieStoreId: "invalid-invalid-invalid",
+ },
+ });
+
+ let searchRes, searchResDownload;
+ // Test default container search
+ searchRes = await getResult(extension, "search", {
+ cookieStoreId: "firefox-default",
+ });
+ equal(
+ searchRes.length,
+ 1,
+ "Default container results length for downloads.search"
+ );
+ [searchResDownload] = searchRes;
+ equal(
+ mimeToCookies(searchResDownload.mime),
+ "cookie_normal=1",
+ "Default container cookies for downloads.search"
+ );
+ // Test default container search with mismatched container
+ searchRes = await getResult(extension, "search", {
+ mime: cookiesToMime("cookie_normal=1"),
+ cookieStoreId: "firefox-container-1",
+ });
+ equal(
+ searchRes.length,
+ 0,
+ "Default container results length for downloads.search when container mismatched"
+ );
+
+ // Test private container search
+ searchRes = await getResult(extension, "search", {
+ cookieStoreId: "firefox-private",
+ });
+ if (privateAllowed) {
+ equal(
+ searchRes.length,
+ 1,
+ "Private container results length for downloads.search"
+ );
+ [searchResDownload] = searchRes;
+ equal(
+ mimeToCookies(searchResDownload.mime),
+ "cookie_private=1",
+ "Private container cookies for downloads.search"
+ );
+ // Test private container search with mismatched container
+ searchRes = await getResult(extension, "search", {
+ mime: cookiesToMime("cookie_private=1"),
+ cookieStoreId: "firefox-container-1",
+ });
+ equal(
+ searchRes.length,
+ 0,
+ "Private container results length for downloads.search when container mismatched"
+ );
+ } else {
+ equal(
+ searchRes.length,
+ 0,
+ "Private container results length for downloads.search when private disallowed"
+ );
+ }
+
+ // Test firefox-container-1 search
+ searchRes = await getResult(extension, "search", {
+ cookieStoreId: "firefox-container-1",
+ });
+ equal(
+ searchRes.length,
+ 1,
+ "firefox-container-1 results length for downloads.search"
+ );
+ [searchResDownload] = searchRes;
+ equal(
+ mimeToCookies(searchResDownload.mime),
+ "cookie_container=1",
+ "firefox-container-1 cookies for downloads.search"
+ );
+ // Test firefox-container-1 search with mismatched container
+ searchRes = await getResult(extension, "search", {
+ mime: cookiesToMime("cookie_container=1"),
+ cookieStoreId: "firefox-default",
+ });
+ equal(
+ searchRes.length,
+ 0,
+ "firefox-container-1 container results length for downloads.search when container mismatched"
+ );
+
+ // Test default container erase with mismatched container
+ await getResult(extension, "erase", {
+ mime: cookiesToMime("cookie_normal=1"),
+ cookieStoreId: "firefox-container-1",
+ });
+ searchRes = await getResult(extension, "search", {
+ mime: cookiesToMime("cookie_normal=1"),
+ });
+ equal(
+ searchRes.length,
+ 1,
+ "Default container results length for downloads.search after erase with mismatched container"
+ );
+
+ // Test private container erase with mismatched container
+ await getResult(extension, "erase", {
+ mime: cookiesToMime("cookie_private=1"),
+ cookieStoreId: "firefox-container-1",
+ });
+ searchRes = await getResult(extension, "search", {
+ mime: cookiesToMime("cookie_private=1"),
+ });
+ equal(
+ searchRes.length,
+ privateAllowed ? 1 : 0,
+ "Private container results length for downloads.search after erase with mismatched container"
+ );
+
+ // Test firefox-container-1 erase with mismatched container
+ await getResult(extension, "erase", {
+ mime: cookiesToMime("cookie_container=1"),
+ cookieStoreId: "firefox-default",
+ });
+ searchRes = await getResult(extension, "search", {
+ mime: cookiesToMime("cookie_container=1"),
+ });
+ equal(
+ searchRes.length,
+ 1,
+ "firefox-container-1 results length for downloads.search after erase with mismatched container"
+ );
+
+ // Test default container erase
+ await getResult(extension, "erase", {
+ cookieStoreId: "firefox-default",
+ });
+ searchRes = await getResult(extension, "search", {
+ mime: cookiesToMime("cookie_normal=1"),
+ });
+ equal(
+ searchRes.length,
+ 0,
+ "Default container results length for downloads.search after erase"
+ );
+
+ // Test private container erase
+ await getResult(extension, "erase", {
+ cookieStoreId: "firefox-private",
+ });
+ searchRes = await getResult(extension, "search", {
+ mime: cookiesToMime("cookie_private=1"),
+ });
+ // The following will also pass when incognito disabled
+ equal(
+ searchRes.length,
+ 0,
+ "Private container results length for downloads.search after erase"
+ );
+
+ // Test firefox-container-1 erase
+ await getResult(extension, "erase", {
+ cookieStoreId: "firefox-container-1",
+ });
+ searchRes = await getResult(extension, "search", {
+ mime: cookiesToMime("cookie_container=1"),
+ });
+ equal(
+ searchRes.length,
+ 0,
+ "firefox-container-1 results length for downloads.search after erase"
+ );
+}
+
+async function populateDownloads(extension) {
+ await getResult(extension, "erase", {});
+ await getResult(extension, "getDownload", {
+ url: DOWNLOAD_URL,
+ });
+ await getResult(extension, "getDownload", {
+ url: DOWNLOAD_URL,
+ incognito: true,
+ });
+ await getResult(extension, "getDownload", {
+ url: DOWNLOAD_URL,
+ cookieStoreId: "firefox-container-1",
+ });
+}
+
+add_task(async function setup() {
+ const nsIFile = Ci.nsIFile;
+ const downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir);
+ Services.prefs.setBoolPref("privacy.userContext.enabled", true);
+ await setUpCookies();
+ registerCleanupFunction(() => {
+ Services.cookies.removeAll();
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ downloadDir.remove(false);
+ });
+});
+
+add_task(async function download_cookieStoreId() {
+ // Test extension with cookies permission and incognito enabled
+ let extension = createDownloadTestExtension(["cookies"], true);
+ await extension.startup();
+ await runTests(extension, true, true);
+
+ // Test extension with incognito enabled and no cookies permission
+ await populateDownloads(extension);
+ let noCookiesExtension = createDownloadTestExtension([], true);
+ await noCookiesExtension.startup();
+ await runTests(noCookiesExtension, false, true);
+ await noCookiesExtension.unload();
+
+ // Test extension with incognito disabled and no cookies permission
+ await populateDownloads(extension);
+ let noCookiesAndPrivateExtension = createDownloadTestExtension([], false);
+ await noCookiesAndPrivateExtension.startup();
+ await runTests(noCookiesAndPrivateExtension, false, false);
+ await noCookiesAndPrivateExtension.unload();
+
+ // Verify that incognito disabled test did not delete private download
+ let searchRes = await getResult(extension, "search", {
+ mime: cookiesToMime("cookie_private=1"),
+ });
+ ok(searchRes.length, "Incognito disabled does not delete private download");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js
new file mode 100644
index 0000000000..8d3984e8e2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js
@@ -0,0 +1,219 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { UrlClassifierTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/UrlClassifierTestUtils.sys.mjs"
+);
+
+// Value for network.cookie.cookieBehavior to reject all third-party cookies.
+const { BEHAVIOR_REJECT_FOREIGN } = Ci.nsICookieService;
+
+const server = createHttpServer({ hosts: ["example.net", "itisatracker.org"] });
+server.registerPathHandler("/setcookies", (request, response) => {
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.setHeader("Set-Cookie", "c_none=1; sameSite=none", true);
+ response.setHeader("Set-Cookie", "c_lax=1; sameSite=lax", true);
+ response.setHeader("Set-Cookie", "c_strict=1; sameSite=strict", true);
+});
+
+server.registerPathHandler("/download", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+
+ let cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : "";
+ // Assign the result through the MIME-type, to make it easier to read the
+ // result via the downloads API.
+ response.setHeader("Content-Type", `dummy/${encodeURIComponent(cookies)}`);
+ // Response of length 7.
+ response.write("1234567");
+});
+
+server.registerPathHandler("/redirect", (request, response) => {
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ response.setHeader("Location", "/download");
+});
+
+function createDownloadTestExtension(extraPermissions = []) {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["downloads", ...extraPermissions],
+ },
+ incognitoOverride: "spanning",
+ background() {
+ async function getCookiesForDownload(url) {
+ let donePromise = new Promise(resolve => {
+ browser.downloads.onChanged.addListener(async delta => {
+ if (delta.state?.current === "complete") {
+ resolve(delta.id);
+ }
+ });
+ });
+ // TODO bug 1653636: Remove this when the correct browsing mode is used.
+ const incognito = browser.extension.inIncognitoContext;
+ let downloadId = await browser.downloads.download({ url, incognito });
+ browser.test.assertEq(await donePromise, downloadId, "got download");
+ let [download] = await browser.downloads.search({ id: downloadId });
+ browser.test.log(`Download results: ${JSON.stringify(download)}`);
+
+ // Delete the file since we aren't interested in it.
+ // TODO bug 1654819: On Windows the file may be recreated.
+ await browser.downloads.removeFile(download.id);
+ // Sanity check to verify that we got the result from /download.
+ browser.test.assertEq(7, download.fileSize, "download succeeded");
+
+ // The "/download" endpoint mirrors received cookies via Content-Type.
+ let cookies = decodeURIComponent(download.mime.replace("dummy/", ""));
+ return cookies;
+ }
+
+ browser.test.onMessage.addListener(async url => {
+ browser.test.sendMessage("result", await getCookiesForDownload(url));
+ });
+ },
+ });
+}
+
+async function downloadAndGetCookies(extension, url) {
+ extension.sendMessage(url);
+ return extension.awaitMessage("result");
+}
+
+add_task(async function setup() {
+ const nsIFile = Ci.nsIFile;
+ const downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir);
+
+ // Support sameSite=none despite the server using http instead of https.
+ Services.prefs.setBoolPref(
+ "network.cookie.sameSite.noneRequiresSecure",
+ false
+ );
+ async function loadAndClose(url) {
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+ await contentPage.close();
+ }
+ // Generate cookies for use in this test.
+ await loadAndClose("http://example.net/setcookies");
+ await loadAndClose("http://itisatracker.org/setcookies");
+
+ await UrlClassifierTestUtils.addTestTrackers();
+ registerCleanupFunction(() => {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ Services.cookies.removeAll();
+
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+
+ downloadDir.remove(false);
+ });
+});
+
+// Checks that (sameSite) cookies are included in download requests.
+add_task(async function download_cookies_basic() {
+ let extension = createDownloadTestExtension(["*://example.net/*"]);
+ await extension.startup();
+
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download with sameSite cookies"
+ );
+
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/redirect"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download with redirect"
+ );
+
+ await runWithPrefs(
+ [["network.cookie.cookieBehavior", BEHAVIOR_REJECT_FOREIGN]],
+ async () => {
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download with all third-party cookies disabled"
+ );
+ }
+ );
+
+ await extension.unload();
+});
+
+// Checks that (sameSite) cookies are included even when tracking protection
+// would block cookies from third-party requests.
+add_task(async function download_cookies_from_tracker_url() {
+ let extension = createDownloadTestExtension(["*://itisatracker.org/*"]);
+ await extension.startup();
+
+ equal(
+ await downloadAndGetCookies(extension, "http://itisatracker.org/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download of itisatracker.org"
+ );
+
+ await extension.unload();
+});
+
+// Checks that (sameSite) cookies are included even without host permissions.
+add_task(async function download_cookies_without_host_permissions() {
+ let extension = createDownloadTestExtension();
+ await extension.startup();
+
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download without host permissions"
+ );
+
+ equal(
+ await downloadAndGetCookies(extension, "http://itisatracker.org/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download of itisatracker.org"
+ );
+
+ await runWithPrefs(
+ [["network.cookie.cookieBehavior", BEHAVIOR_REJECT_FOREIGN]],
+ async () => {
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download with all third-party cookies disabled"
+ );
+ }
+ );
+
+ await extension.unload();
+});
+
+// Checks that (sameSite) cookies from private browsing are included.
+add_task(async function download_cookies_in_perma_private_browsing() {
+ Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true);
+ Services.prefs.setBoolPref("dom.security.https_first_pbm", false);
+
+ let extension = createDownloadTestExtension(["*://example.net/*"]);
+ await extension.startup();
+
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/download"),
+ "",
+ "Initially no cookies in permanent private browsing mode"
+ );
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.net/setcookies",
+ { privateBrowsing: true }
+ );
+
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download in perma-private-browsing mode"
+ );
+
+ await extension.unload();
+ await contentPage.close();
+ Services.prefs.clearUserPref("browser.privatebrowsing.autostart");
+ Services.prefs.clearUserPref("dom.security.https_first_pbm");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js
new file mode 100644
index 0000000000..e2867d1f03
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js
@@ -0,0 +1,685 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { Downloads } = ChromeUtils.importESModule(
+ "resource://gre/modules/Downloads.sys.mjs"
+);
+
+const gServer = createHttpServer();
+gServer.registerDirectory("/data/", do_get_file("data"));
+
+gServer.registerPathHandler("/dir/", (_, res) => res.write("length=8"));
+
+const WINDOWS = AppConstants.platform == "win";
+
+const BASE = `http://localhost:${gServer.identity.primaryPort}/`;
+const FILE_NAME = "file_download.txt";
+const FILE_NAME_W_SPACES = "file download.txt";
+const FILE_URL = BASE + "data/" + FILE_NAME;
+const FILE_NAME_UNIQUE = "file_download(1).txt";
+const FILE_LEN = 46;
+
+let downloadDir;
+
+function joinPath(...components) {
+ const separator = WINDOWS ? "\\" : "/";
+
+ return components.join(separator);
+}
+
+function setup() {
+ downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ info(`Using download directory ${downloadDir.path}`);
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue(
+ "browser.download.dir",
+ Ci.nsIFile,
+ downloadDir
+ );
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+
+ let entries = downloadDir.directoryEntries;
+ while (entries.hasMoreElements()) {
+ let entry = entries.nextFile;
+ ok(false, `Leftover file ${entry.path} in download directory`);
+ entry.remove(false);
+ }
+
+ downloadDir.remove(false);
+ });
+}
+
+function backgroundScript() {
+ let blobUrl;
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ if (msg == "download.request") {
+ let options = args[0];
+
+ if (options.blobme) {
+ let blob = new Blob(options.blobme);
+ delete options.blobme;
+ blobUrl = options.url = window.URL.createObjectURL(blob);
+ }
+
+ try {
+ let id = await browser.downloads.download(options);
+ browser.test.sendMessage("download.done", { status: "success", id });
+ } catch (error) {
+ browser.test.sendMessage("download.done", {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ } else if (msg == "killTheBlob") {
+ window.URL.revokeObjectURL(blobUrl);
+ blobUrl = null;
+ }
+ });
+
+ browser.test.sendMessage("ready");
+}
+
+// This function is a bit of a sledgehammer, it looks at every download
+// the browser knows about and waits for all active downloads to complete.
+// But we only start one at a time and only do a handful in total, so
+// this lets us test download() without depending on anything else.
+async function waitForDownloads() {
+ let list = await Downloads.getList(Downloads.ALL);
+ let downloads = await list.getAll();
+
+ let inprogress = downloads.filter(dl => !dl.stopped);
+ return Promise.all(inprogress.map(dl => dl.whenSucceeded()));
+}
+
+// Create a file in the downloads directory.
+function touch(filename) {
+ let file = downloadDir.clone();
+ file.append(filename);
+ file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+}
+
+// Remove a file in the downloads directory.
+function remove(filename, recursive = false) {
+ let file = downloadDir.clone();
+ file.append(filename);
+ file.remove(recursive);
+}
+
+add_task(async function test_downloads() {
+ setup();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})()`,
+ manifest: {
+ permissions: ["downloads"],
+ },
+ incognitoOverride: "spanning",
+ });
+
+ function download(options) {
+ extension.sendMessage("download.request", options);
+ return extension.awaitMessage("download.done");
+ }
+
+ async function testDownload(options, localFile, expectedSize, description) {
+ let msg = await download(options);
+ equal(
+ msg.status,
+ "success",
+ `downloads.download() works with ${description}`
+ );
+
+ await waitForDownloads();
+
+ let localPath = downloadDir.clone();
+ let parts = Array.isArray(localFile) ? localFile : [localFile];
+
+ parts.map(p => localPath.append(p));
+ equal(
+ localPath.fileSize,
+ expectedSize,
+ "Downloaded file has expected size"
+ );
+ localPath.remove(false);
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ info("extension started");
+
+ // Call download() with just the url property.
+ await testDownload({ url: FILE_URL }, FILE_NAME, FILE_LEN, "just source");
+
+ // Call download() with a filename property.
+ await testDownload(
+ {
+ url: FILE_URL,
+ filename: "newpath.txt",
+ },
+ "newpath.txt",
+ FILE_LEN,
+ "source and filename"
+ );
+
+ // Call download() with a filename with subdirs.
+ await testDownload(
+ {
+ url: FILE_URL,
+ filename: "sub/dir/file",
+ },
+ ["sub", "dir", "file"],
+ FILE_LEN,
+ "source and filename with subdirs"
+ );
+
+ // Call download() with a filename with existing subdirs.
+ await testDownload(
+ {
+ url: FILE_URL,
+ filename: "sub/dir/file2",
+ },
+ ["sub", "dir", "file2"],
+ FILE_LEN,
+ "source and filename with existing subdirs"
+ );
+
+ // Only run Windows path separator test on Windows.
+ if (WINDOWS) {
+ // Call download() with a filename with Windows path separator.
+ await testDownload(
+ {
+ url: FILE_URL,
+ filename: "sub\\dir\\file3",
+ },
+ ["sub", "dir", "file3"],
+ FILE_LEN,
+ "filename with Windows path separator"
+ );
+ }
+ remove("sub", true);
+
+ // Call download(), filename with subdir, skipping parts.
+ await testDownload(
+ {
+ url: FILE_URL,
+ filename: "skip//part",
+ },
+ ["skip", "part"],
+ FILE_LEN,
+ "source, filename, with subdir, skipping parts"
+ );
+ remove("skip", true);
+
+ // Check conflictAction of "uniquify".
+ touch(FILE_NAME);
+ await testDownload(
+ {
+ url: FILE_URL,
+ conflictAction: "uniquify",
+ },
+ FILE_NAME_UNIQUE,
+ FILE_LEN,
+ "conflictAction=uniquify"
+ );
+ // todo check that preexisting file was not modified?
+ remove(FILE_NAME);
+
+ // Check conflictAction of "overwrite".
+ touch(FILE_NAME);
+ await testDownload(
+ {
+ url: FILE_URL,
+ conflictAction: "overwrite",
+ },
+ FILE_NAME,
+ FILE_LEN,
+ "conflictAction=overwrite"
+ );
+
+ // Try to download in invalid url
+ await download({ url: "this is not a valid URL" }).then(msg => {
+ equal(msg.status, "error", "downloads.download() fails with invalid url");
+ ok(
+ /not a valid URL/.test(msg.errmsg),
+ "error message for invalid url is correct"
+ );
+ });
+
+ // Try to download to an empty path.
+ await download({
+ url: FILE_URL,
+ filename: "",
+ }).then(msg => {
+ equal(
+ msg.status,
+ "error",
+ "downloads.download() fails with empty filename"
+ );
+ equal(
+ msg.errmsg,
+ "filename must not be empty",
+ "error message for empty filename is correct"
+ );
+ });
+
+ // Try to download to an absolute path.
+ const absolutePath = PathUtils.join(
+ WINDOWS ? "C:\\tmp" : "/tmp",
+ "file_download.txt"
+ );
+ await download({
+ url: FILE_URL,
+ filename: absolutePath,
+ }).then(msg => {
+ equal(
+ msg.status,
+ "error",
+ "downloads.download() fails with absolute filename"
+ );
+ equal(
+ msg.errmsg,
+ "filename must not be an absolute path",
+ `error message for absolute path (${absolutePath}) is correct`
+ );
+ });
+
+ if (WINDOWS) {
+ await download({
+ url: FILE_URL,
+ filename: "C:\\file_download.txt",
+ }).then(msg => {
+ equal(
+ msg.status,
+ "error",
+ "downloads.download() fails with absolute filename"
+ );
+ equal(
+ msg.errmsg,
+ "filename must not be an absolute path",
+ "error message for absolute path with drive letter is correct"
+ );
+ });
+ }
+
+ // Try to download to a relative path containing ..
+ await download({
+ url: FILE_URL,
+ filename: joinPath("..", "file_download.txt"),
+ }).then(msg => {
+ equal(
+ msg.status,
+ "error",
+ "downloads.download() fails with back-references"
+ );
+ equal(
+ msg.errmsg,
+ "filename must not contain back-references (..)",
+ "error message for back-references is correct"
+ );
+ });
+
+ // Try to download to a long relative path containing ..
+ await download({
+ url: FILE_URL,
+ filename: joinPath("foo", "..", "..", "file_download.txt"),
+ }).then(msg => {
+ equal(
+ msg.status,
+ "error",
+ "downloads.download() fails with back-references"
+ );
+ equal(
+ msg.errmsg,
+ "filename must not contain back-references (..)",
+ "error message for back-references is correct"
+ );
+ });
+
+ // Test illegal characters.
+ await download({
+ url: FILE_URL,
+ filename: "like:this",
+ }).then(msg => {
+ equal(msg.status, "error", "downloads.download() fails with illegal chars");
+ equal(
+ msg.errmsg,
+ "filename must not contain illegal characters",
+ "error message correct"
+ );
+ });
+
+ // Try to download a blob url
+ const BLOB_STRING = "Hello, world";
+ await testDownload(
+ {
+ blobme: [BLOB_STRING],
+ filename: FILE_NAME,
+ },
+ FILE_NAME,
+ BLOB_STRING.length,
+ "blob url"
+ );
+ extension.sendMessage("killTheBlob");
+
+ // Try to download a blob url without a given filename
+ await testDownload(
+ {
+ blobme: [BLOB_STRING],
+ },
+ "download",
+ BLOB_STRING.length,
+ "blob url with no filename"
+ );
+ extension.sendMessage("killTheBlob");
+
+ // Download a normal URL with an empty filename part.
+ await testDownload(
+ {
+ url: BASE + "dir/",
+ },
+ "download",
+ 8,
+ "normal url with empty filename"
+ );
+
+ // Download a filename with multiple spaces, url is ignored for this test.
+ await testDownload(
+ {
+ url: FILE_URL,
+ filename: "a file.txt",
+ },
+ "a file.txt",
+ FILE_LEN,
+ "filename with multiple spaces"
+ );
+
+ // Download a normal URL with a leafname containing multiple spaces.
+ // Note: spaces are compressed by file name normalization.
+ await testDownload(
+ {
+ url: BASE + "data/" + FILE_NAME_W_SPACES,
+ },
+ FILE_NAME_W_SPACES.replace(/\s+/, " "),
+ FILE_LEN,
+ "leafname with multiple spaces"
+ );
+
+ // Check that the "incognito" property is supported.
+ await testDownload(
+ {
+ url: FILE_URL,
+ incognito: false,
+ },
+ FILE_NAME,
+ FILE_LEN,
+ "incognito=false"
+ );
+
+ await testDownload(
+ {
+ url: FILE_URL,
+ incognito: true,
+ },
+ FILE_NAME,
+ FILE_LEN,
+ "incognito=true"
+ );
+
+ await extension.unload();
+});
+
+async function testHttpErrors(allowHttpErrors) {
+ const server = createHttpServer();
+ const url = `http://localhost:${server.identity.primaryPort}/error`;
+ const content = "HTTP Error test";
+
+ server.registerPathHandler("/error", (request, response) => {
+ response.setStatusLine(
+ "1.1",
+ parseInt(request.queryString, 10),
+ "Some Error"
+ );
+ response.setHeader("Content-Type", "text/plain", false);
+ response.setHeader("Content-Length", content.length.toString());
+ response.write(content);
+ });
+
+ function background(code) {
+ let dlid = 0;
+ let expectedState;
+ browser.test.onMessage.addListener(async options => {
+ try {
+ expectedState = options.allowHttpErrors ? "complete" : "interrupted";
+ dlid = await browser.downloads.download(options);
+ } catch (err) {
+ browser.test.fail(`Unexpected error in downloads.download(): ${err}`);
+ }
+ });
+ function onChanged({ id, state }) {
+ if (dlid !== id || !state || state.current === "in_progress") {
+ return;
+ }
+ browser.test.assertEq(state.current, expectedState, "correct state");
+ browser.downloads.search({ id }).then(([download]) => {
+ browser.test.sendMessage("done", download.error);
+ });
+ }
+ browser.downloads.onChanged.addListener(onChanged);
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["downloads"],
+ },
+ background,
+ });
+ await extension.startup();
+
+ async function download(code, expected_when_disallowed) {
+ const options = {
+ url: url + "?" + code,
+ filename: `test-${code}`,
+ conflictAction: "overwrite",
+ allowHttpErrors,
+ };
+ extension.sendMessage(options);
+ const rv = await extension.awaitMessage("done");
+
+ if (allowHttpErrors) {
+ const localPath = downloadDir.clone();
+ localPath.append(options.filename);
+ equal(
+ localPath.fileSize,
+ // The 20x No content errors will not produce any response body,
+ // only "true" errors do.
+ code >= 400 ? content.length : 0,
+ "Downloaded file has expected size" + code
+ );
+ localPath.remove(false);
+
+ ok(!rv, "error must be ignored and hence false-y");
+ return;
+ }
+
+ equal(
+ rv,
+ expected_when_disallowed,
+ "error must have the correct InterruptReason"
+ );
+ }
+
+ await download(204, "SERVER_BAD_CONTENT"); // No Content
+ await download(205, "SERVER_BAD_CONTENT"); // Reset Content
+ await download(404, "SERVER_BAD_CONTENT"); // Not Found
+ await download(403, "SERVER_FORBIDDEN"); // Forbidden
+ await download(402, "SERVER_UNAUTHORIZED"); // Unauthorized
+ await download(407, "SERVER_UNAUTHORIZED"); // Proxy auth required
+ await download(504, "SERVER_FAILED"); //General errors, here Gateway Timeout
+
+ await extension.unload();
+}
+
+add_task(function test_download_disallowed_http_errors() {
+ return testHttpErrors(false);
+});
+
+add_task(function test_download_allowed_http_errors() {
+ return testHttpErrors(true);
+});
+
+add_task(async function test_download_http_details() {
+ const server = createHttpServer();
+ const url = `http://localhost:${server.identity.primaryPort}/post-log`;
+
+ let received;
+ server.registerPathHandler("/post-log", (request, response) => {
+ received = request;
+ response.setHeader("Set-Cookie", "monster=", false);
+ });
+
+ // Confirm received vs. expected values.
+ function confirm(method, headers = {}, body) {
+ equal(received.method, method, "method is correct");
+
+ for (let name in headers) {
+ ok(received.hasHeader(name), `header ${name} received`);
+ equal(
+ received.getHeader(name),
+ headers[name],
+ `header ${name} is correct`
+ );
+ }
+
+ if (body) {
+ const str = NetUtil.readInputStreamToString(
+ received.bodyInputStream,
+ received.bodyInputStream.available()
+ );
+ equal(str, body, "body is correct");
+ }
+ }
+
+ function background() {
+ browser.test.onMessage.addListener(async options => {
+ try {
+ await browser.downloads.download(options);
+ } catch (err) {
+ browser.test.sendMessage("done", { err: err.message });
+ }
+ });
+ browser.downloads.onChanged.addListener(({ state }) => {
+ if (state && state.current === "complete") {
+ browser.test.sendMessage("done", { ok: true });
+ }
+ });
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["downloads"],
+ },
+ background,
+ incognitoOverride: "spanning",
+ });
+ await extension.startup();
+
+ function download(options) {
+ options.url = url;
+ options.conflictAction = "overwrite";
+
+ extension.sendMessage(options);
+ return extension.awaitMessage("done");
+ }
+
+ // Test that site cookies are sent with download requests,
+ // and "incognito" downloads use a separate cookie jar.
+ let testDownloadCookie = async function (incognito) {
+ let result = await download({ incognito });
+ ok(result.ok, `preflight to set cookies with incognito=${incognito}`);
+ ok(!received.hasHeader("cookie"), "first request has no cookies");
+
+ result = await download({ incognito });
+ ok(result.ok, `download with cookie with incognito=${incognito}`);
+ equal(
+ received.getHeader("cookie"),
+ "monster=",
+ "correct cookie header sent for second download"
+ );
+ };
+
+ await testDownloadCookie(false);
+ await testDownloadCookie(true);
+
+ // Test method option.
+ let result = await download({});
+ ok(result.ok, "download works without the method option, defaults to GET");
+ confirm("GET");
+
+ result = await download({ method: "PUT" });
+ ok(!result.ok, "download rejected with PUT method");
+ ok(
+ /method: Invalid enumeration/.test(result.err),
+ "descriptive error message"
+ );
+
+ result = await download({ method: "POST" });
+ ok(result.ok, "download works with POST method");
+ confirm("POST");
+
+ // Test body option values.
+ result = await download({ body: [] });
+ ok(!result.ok, "download rejected because of non-string body");
+ ok(/body: Expected string/.test(result.err), "descriptive error message");
+
+ result = await download({ method: "POST", body: "of work" });
+ ok(result.ok, "download works with POST method and body");
+ confirm("POST", { "Content-Length": 7 }, "of work");
+
+ // Test custom headers.
+ result = await download({ headers: [{ name: "X-Custom" }] });
+ ok(!result.ok, "download rejected because of missing header value");
+ ok(/"value" is required/.test(result.err), "descriptive error message");
+
+ result = await download({ headers: [{ name: "X-Custom", value: "13" }] });
+ ok(result.ok, "download works with a custom header");
+ confirm("GET", { "X-Custom": "13" });
+
+ // Test Referer header.
+ const referer = "http://example.org/test";
+ result = await download({ headers: [{ name: "Referer", value: referer }] });
+ ok(result.ok, "download works with Referer header");
+ confirm("GET", { Referer: referer });
+
+ // Test forbidden headers.
+ result = await download({ headers: [{ name: "DNT", value: "1" }] });
+ ok(!result.ok, "download rejected because of forbidden header name DNT");
+ ok(/Forbidden request header/.test(result.err), "descriptive error message");
+
+ result = await download({
+ headers: [{ name: "Proxy-Connection", value: "keep" }],
+ });
+ ok(
+ !result.ok,
+ "download rejected because of forbidden header name prefix Proxy-"
+ );
+ ok(/Forbidden request header/.test(result.err), "descriptive error message");
+
+ result = await download({ headers: [{ name: "Sec-ret", value: "13" }] });
+ ok(
+ !result.ok,
+ "download rejected because of forbidden header name prefix Sec-"
+ );
+ ok(/Forbidden request header/.test(result.err), "descriptive error message");
+
+ remove("post-log");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_eventpage.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_eventpage.js
new file mode 100644
index 0000000000..9c71c63e96
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_eventpage.js
@@ -0,0 +1,162 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE = `http://localhost:${server.identity.primaryPort}/data`;
+const TXT_FILE = "file_download.txt";
+const TXT_URL = BASE + "/" + TXT_FILE;
+
+add_task(function setup() {
+ let downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ info(`Using download directory ${downloadDir.path}`);
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue(
+ "browser.download.dir",
+ Ci.nsIFile,
+ downloadDir
+ );
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+
+ let entries = downloadDir.directoryEntries;
+ while (entries.hasMoreElements()) {
+ let entry = entries.nextFile;
+ ok(false, `Leftover file ${entry.path} in download directory`);
+ entry.remove(false);
+ }
+
+ downloadDir.remove(false);
+ });
+});
+
+add_task(
+ { pref_set: [["extensions.eventPages.enabled", true]] },
+ async function test_downloads_event_page() {
+ await AddonTestUtils.promiseStartupManager();
+
+ // A simple download driving extension
+ let dl_extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "downloader@mochitest" } },
+ permissions: ["downloads"],
+ background: { persistent: false },
+ },
+ background() {
+ let downloadId;
+ browser.downloads.onChanged.addListener(async info => {
+ if (info.state && info.state.current === "complete") {
+ browser.test.sendMessage("downloadComplete");
+ }
+ });
+ browser.test.onMessage.addListener(async (msg, opts) => {
+ if (msg == "download") {
+ downloadId = await browser.downloads.download(opts);
+ }
+ if (msg == "erase") {
+ await browser.downloads.removeFile(downloadId);
+ await browser.downloads.erase({ id: downloadId });
+ }
+ });
+ },
+ });
+ await dl_extension.startup();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["downloads"],
+ background: { persistent: false },
+ },
+ background() {
+ browser.downloads.onChanged.addListener(() => {
+ browser.test.sendMessage("onChanged");
+ });
+ browser.downloads.onCreated.addListener(() => {
+ browser.test.sendMessage("onCreated");
+ });
+ browser.downloads.onErased.addListener(() => {
+ browser.test.sendMessage("onErased");
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ // onDeterminingFilename is never persisted, it is an empty event handler.
+ const EVENTS = ["onChanged", "onCreated", "onErased"];
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ for (let event of EVENTS) {
+ assertPersistentListeners(extension, "downloads", event, {
+ primed: false,
+ });
+ }
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ ok(
+ !extension.extension.backgroundContext,
+ "Background Extension context should have been destroyed"
+ );
+
+ for (let event of EVENTS) {
+ assertPersistentListeners(extension, "downloads", event, {
+ primed: true,
+ });
+ }
+
+ // test download events waken background
+ dl_extension.sendMessage("download", {
+ url: TXT_URL,
+ filename: TXT_FILE,
+ });
+ await extension.awaitMessage("ready");
+ await extension.awaitMessage("onCreated");
+ for (let event of EVENTS) {
+ assertPersistentListeners(extension, "downloads", event, {
+ primed: false,
+ });
+ }
+ await extension.awaitMessage("onChanged");
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ ok(
+ !extension.extension.backgroundContext,
+ "Background Extension context should have been destroyed"
+ );
+
+ await dl_extension.awaitMessage("downloadComplete");
+ dl_extension.sendMessage("erase");
+ await extension.awaitMessage("ready");
+ await extension.awaitMessage("onErased");
+ await dl_extension.unload();
+
+ // check primed listeners after startup
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+
+ for (let event of EVENTS) {
+ assertPersistentListeners(extension, "downloads", event, {
+ primed: true,
+ });
+ }
+
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js
new file mode 100644
index 0000000000..d315d37aaa
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js
@@ -0,0 +1,1169 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { Downloads } = ChromeUtils.importESModule(
+ "resource://gre/modules/Downloads.sys.mjs"
+);
+
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const ROOT = `http://localhost:${server.identity.primaryPort}`;
+const BASE = `${ROOT}/data`;
+const TXT_FILE = "file_download.txt";
+const TXT_URL = BASE + "/" + TXT_FILE;
+
+// Keep these in sync with code in interruptible.sjs
+const INT_PARTIAL_LEN = 15;
+const INT_TOTAL_LEN = 31;
+
+const TEST_DATA = "This is 31 bytes of sample data";
+const TOTAL_LEN = TEST_DATA.length;
+const PARTIAL_LEN = 15;
+
+// A handler to let us systematically test pausing/resuming/canceling
+// of downloads. This target represents a small text file but a simple
+// GET will stall after sending part of the data, to give the test code
+// a chance to pause or do other operations on an in-progress download.
+// A resumed download (ie, a GET with a Range: header) will allow the
+// download to complete.
+function handleRequest(request, response) {
+ response.setHeader("Content-Type", "text/plain", false);
+
+ if (request.hasHeader("Range")) {
+ let start, end;
+ let matches = request
+ .getHeader("Range")
+ .match(/^\s*bytes=(\d+)?-(\d+)?\s*$/);
+ if (matches != null) {
+ start = matches[1] ? parseInt(matches[1], 10) : 0;
+ end = matches[2] ? parseInt(matches[2], 10) : TOTAL_LEN - 1;
+ }
+
+ if (end == undefined || end >= TOTAL_LEN) {
+ response.setStatusLine(
+ request.httpVersion,
+ 416,
+ "Requested Range Not Satisfiable"
+ );
+ response.setHeader("Content-Range", `*/${TOTAL_LEN}`, false);
+ response.finish();
+ return;
+ }
+
+ response.setStatusLine(request.httpVersion, 206, "Partial Content");
+ response.setHeader("Content-Range", `${start}-${end}/${TOTAL_LEN}`, false);
+ response.write(TEST_DATA.slice(start, end + 1));
+ } else if (request.queryString.includes("stream")) {
+ response.processAsync();
+ response.setHeader("Content-Length", "10000", false);
+ response.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
+ setInterval(() => {
+ response.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
+ }, 50);
+ } else {
+ response.processAsync();
+ response.setHeader("Content-Length", `${TOTAL_LEN}`, false);
+ response.write(TEST_DATA.slice(0, PARTIAL_LEN));
+ }
+
+ registerCleanupFunction(() => {
+ try {
+ response.finish();
+ } catch (e) {
+ // This will throw, but we don't care at this point.
+ }
+ });
+}
+
+server.registerPrefixHandler("/interruptible/", handleRequest);
+
+let interruptibleCount = 0;
+function getInterruptibleUrl(filename = "interruptible.html") {
+ let n = interruptibleCount++;
+ return `${ROOT}/interruptible/${filename}?count=${n}`;
+}
+
+function backgroundScript() {
+ let events = new Set();
+ let eventWaiter = null;
+
+ browser.downloads.onCreated.addListener(data => {
+ events.add({ type: "onCreated", data });
+ if (eventWaiter) {
+ eventWaiter();
+ }
+ });
+
+ browser.downloads.onChanged.addListener(data => {
+ events.add({ type: "onChanged", data });
+ if (eventWaiter) {
+ eventWaiter();
+ }
+ });
+
+ browser.downloads.onErased.addListener(data => {
+ events.add({ type: "onErased", data });
+ if (eventWaiter) {
+ eventWaiter();
+ }
+ });
+
+ // Returns a promise that will resolve when the given list of expected
+ // events have all been seen. By default, succeeds only if the exact list
+ // of expected events is seen in the given order. options.exact can be
+ // set to false to allow other events and options.inorder can be set to
+ // false to allow the events to arrive in any order.
+ function waitForEvents(expected, options = {}) {
+ function compare(a, b) {
+ if (typeof b == "object" && b != null) {
+ if (typeof a != "object") {
+ return false;
+ }
+ return Object.keys(b).every(fld => compare(a[fld], b[fld]));
+ }
+ return a == b;
+ }
+
+ const exact = "exact" in options ? options.exact : true;
+ const inorder = "inorder" in options ? options.inorder : true;
+ return new Promise((resolve, reject) => {
+ function check() {
+ function fail(msg) {
+ browser.test.fail(msg);
+ reject(new Error(msg));
+ }
+ if (events.size < expected.length) {
+ return;
+ }
+ if (exact && expected.length < events.size) {
+ fail(
+ `Got ${events.size} events but only expected ${expected.length}`
+ );
+ return;
+ }
+
+ let remaining = new Set(events);
+ if (inorder) {
+ for (let event of events) {
+ if (compare(event, expected[0])) {
+ expected.shift();
+ remaining.delete(event);
+ }
+ }
+ } else {
+ expected = expected.filter(val => {
+ for (let remainingEvent of remaining) {
+ if (compare(remainingEvent, val)) {
+ remaining.delete(remainingEvent);
+ return false;
+ }
+ }
+ return true;
+ });
+ }
+
+ // Events that did occur have been removed from expected so if
+ // expected is empty, we're done. If we didn't see all the
+ // expected events and we're not looking for an exact match,
+ // then we just may not have seen the event yet, so return without
+ // failing and check() will be called again when a new event arrives.
+ if (!expected.length) {
+ events = remaining;
+ eventWaiter = null;
+ resolve();
+ } else if (exact) {
+ fail(
+ `Mismatched event: expecting ${JSON.stringify(
+ expected[0]
+ )} but got ${JSON.stringify(Array.from(remaining)[0])}`
+ );
+ }
+ }
+ eventWaiter = check;
+ check();
+ });
+ }
+
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ let match = msg.match(/(\w+).request$/);
+ if (!match) {
+ return;
+ }
+
+ let what = match[1];
+ if (what == "waitForEvents") {
+ try {
+ await waitForEvents(...args);
+ browser.test.sendMessage("waitForEvents.done", { status: "success" });
+ } catch (error) {
+ browser.test.sendMessage("waitForEvents.done", {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ } else if (what == "clearEvents") {
+ events = new Set();
+ browser.test.sendMessage("clearEvents.done", { status: "success" });
+ } else {
+ try {
+ let result = await browser.downloads[what](...args);
+ browser.test.sendMessage(`${what}.done`, { status: "success", result });
+ } catch (error) {
+ browser.test.sendMessage(`${what}.done`, {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ }
+ });
+
+ browser.test.sendMessage("ready");
+}
+
+let downloadDir;
+let extension;
+
+async function waitForCreatedPartFile(baseFilename = "interruptible.html") {
+ const partFilePath = PathUtils.join(downloadDir.path, `${baseFilename}.part`);
+
+ info(`Wait for ${partFilePath} to be created`);
+ let lastError;
+ await TestUtils.waitForCondition(
+ () =>
+ IOUtils.exists(partFilePath).catch(err => {
+ lastError = err;
+ return false;
+ }),
+ `Wait for the ${partFilePath} to exists before pausing the download`
+ ).catch(err => {
+ if (lastError) {
+ throw lastError;
+ }
+ throw err;
+ });
+}
+
+async function clearDownloads() {
+ let list = await Downloads.getList(Downloads.ALL);
+ let downloads = await list.getAll();
+
+ await Promise.all(
+ downloads.map(async download => {
+ await download.finalize(true);
+ list.remove(download);
+ })
+ );
+
+ return downloads;
+}
+
+function runInExtension(what, ...args) {
+ extension.sendMessage(`${what}.request`, ...args);
+ return extension.awaitMessage(`${what}.done`);
+}
+
+// This is pretty simplistic, it looks for a progress update for a
+// download of the given url in which the total bytes are exactly equal
+// to the given value. Unless you know exactly how data will arrive from
+// the server (eg see interruptible.sjs), it probably isn't very useful.
+async function waitForProgress(url, testFn) {
+ let list = await Downloads.getList(Downloads.ALL);
+
+ return new Promise(resolve => {
+ const view = {
+ onDownloadChanged(download) {
+ if (download.source.url == url && testFn(download.currentBytes)) {
+ list.removeView(view);
+ resolve(download.currentBytes);
+ }
+ },
+ };
+ list.addView(view);
+ });
+}
+
+add_setup(async () => {
+ const nsIFile = Ci.nsIFile;
+ downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`downloadDir ${downloadDir.path}`);
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir);
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ await clearDownloads();
+ downloadDir.remove(true);
+ });
+
+ await clearDownloads().then(downloads => {
+ info(`removed ${downloads.length} pre-existing downloads from history`);
+ });
+
+ extension = ExtensionTestUtils.loadExtension({
+ background: backgroundScript,
+ manifest: {
+ permissions: ["downloads"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ registerCleanupFunction(async () => {
+ await extension.unload();
+ });
+});
+
+add_task(async function test_events() {
+ let msg = await runInExtension("download", { url: TXT_URL });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id, url: TXT_URL } },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "complete",
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onCreated and onChanged events");
+});
+
+add_task(async function test_cancel() {
+ let url = getInterruptibleUrl();
+ info(url);
+ let msg = await runInExtension("download", { url });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN);
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id } },
+ ]);
+ equal(msg.status, "success", "got created and changed events");
+
+ await progressPromise;
+ info(`download reached ${INT_PARTIAL_LEN} bytes`);
+
+ msg = await runInExtension("cancel", id);
+ equal(msg.status, "success", "cancel() succeeded");
+
+ // TODO bug 1256243: This sequence of events is bogus
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ state: {
+ previous: "in_progress",
+ current: "interrupted",
+ },
+ paused: {
+ previous: false,
+ current: true,
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ error: {
+ previous: null,
+ current: "USER_CANCELED",
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ paused: {
+ previous: true,
+ current: false,
+ },
+ },
+ },
+ ]);
+ equal(
+ msg.status,
+ "success",
+ "got onChanged events corresponding to cancel()"
+ );
+
+ msg = await runInExtension("search", { error: "USER_CANCELED" });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ equal(msg.result[0].id, id, "download.id is correct");
+ equal(msg.result[0].state, "interrupted", "download.state is correct");
+ equal(msg.result[0].paused, false, "download.paused is correct");
+ equal(
+ msg.result[0].estimatedEndTime,
+ null,
+ "download.estimatedEndTime is correct"
+ );
+ equal(msg.result[0].canResume, false, "download.canResume is correct");
+ equal(msg.result[0].error, "USER_CANCELED", "download.error is correct");
+ equal(
+ msg.result[0].totalBytes,
+ INT_TOTAL_LEN,
+ "download.totalBytes is correct"
+ );
+ equal(msg.result[0].exists, false, "download.exists is correct");
+
+ msg = await runInExtension("pause", id);
+ equal(msg.status, "error", "cannot pause a canceled download");
+
+ msg = await runInExtension("resume", id);
+ equal(msg.status, "error", "cannot resume a canceled download");
+});
+
+add_task(async function test_pauseresume() {
+ const filename = "pauseresume.html";
+ let url = getInterruptibleUrl(filename);
+ let msg = await runInExtension("download", { url });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN);
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id } },
+ ]);
+ equal(msg.status, "success", "got created and changed events");
+
+ await progressPromise;
+ info(`download reached ${INT_PARTIAL_LEN} bytes`);
+
+ // Prevent intermittent timeouts due to the part file not yet created
+ // (e.g. see Bug 1573360).
+ await waitForCreatedPartFile(filename);
+
+ info("Pause the download item");
+ msg = await runInExtension("pause", id);
+ equal(msg.status, "success", "pause() succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "interrupted",
+ },
+ paused: {
+ previous: false,
+ current: true,
+ },
+ canResume: {
+ previous: false,
+ current: true,
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ error: {
+ previous: null,
+ current: "USER_CANCELED",
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onChanged event corresponding to pause");
+
+ msg = await runInExtension("search", { paused: true });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ equal(msg.result[0].id, id, "download.id is correct");
+ equal(msg.result[0].state, "interrupted", "download.state is correct");
+ equal(msg.result[0].paused, true, "download.paused is correct");
+ equal(
+ msg.result[0].estimatedEndTime,
+ null,
+ "download.estimatedEndTime is correct"
+ );
+ equal(msg.result[0].canResume, true, "download.canResume is correct");
+ equal(msg.result[0].error, "USER_CANCELED", "download.error is correct");
+ equal(
+ msg.result[0].bytesReceived,
+ INT_PARTIAL_LEN,
+ "download.bytesReceived is correct"
+ );
+ equal(
+ msg.result[0].totalBytes,
+ INT_TOTAL_LEN,
+ "download.totalBytes is correct"
+ );
+ equal(msg.result[0].exists, false, "download.exists is correct");
+
+ msg = await runInExtension("search", { error: "USER_CANCELED" });
+ equal(msg.status, "success", "search() succeeded");
+ let found = msg.result.filter(item => item.id == id);
+ equal(found.length, 1, "search() by error found the paused download");
+
+ msg = await runInExtension("pause", id);
+ equal(msg.status, "error", "cannot pause an already paused download");
+
+ msg = await runInExtension("resume", id);
+ equal(msg.status, "success", "resume() succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "interrupted",
+ current: "in_progress",
+ },
+ paused: {
+ previous: true,
+ current: false,
+ },
+ canResume: {
+ previous: true,
+ current: false,
+ },
+ error: {
+ previous: "USER_CANCELED",
+ current: null,
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "complete",
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onChanged events for resume and complete");
+
+ msg = await runInExtension("search", { id });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ equal(msg.result[0].state, "complete", "download.state is correct");
+ equal(msg.result[0].paused, false, "download.paused is correct");
+ equal(
+ msg.result[0].estimatedEndTime,
+ null,
+ "download.estimatedEndTime is correct"
+ );
+ equal(msg.result[0].canResume, false, "download.canResume is correct");
+ equal(msg.result[0].error, null, "download.error is correct");
+ equal(
+ msg.result[0].bytesReceived,
+ INT_TOTAL_LEN,
+ "download.bytesReceived is correct"
+ );
+ equal(
+ msg.result[0].totalBytes,
+ INT_TOTAL_LEN,
+ "download.totalBytes is correct"
+ );
+ equal(msg.result[0].exists, true, "download.exists is correct");
+
+ msg = await runInExtension("pause", id);
+ equal(msg.status, "error", "cannot pause a completed download");
+
+ msg = await runInExtension("resume", id);
+ equal(msg.status, "error", "cannot resume a completed download");
+});
+
+add_task(async function test_pausecancel() {
+ let url = getInterruptibleUrl();
+ let msg = await runInExtension("download", { url });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN);
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id } },
+ ]);
+ equal(msg.status, "success", "got created and changed events");
+
+ await progressPromise;
+ info(`download reached ${INT_PARTIAL_LEN} bytes`);
+
+ msg = await runInExtension("pause", id);
+ equal(msg.status, "success", "pause() succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "interrupted",
+ },
+ paused: {
+ previous: false,
+ current: true,
+ },
+ canResume: {
+ previous: false,
+ current: true,
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ error: {
+ previous: null,
+ current: "USER_CANCELED",
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onChanged event corresponding to pause");
+
+ msg = await runInExtension("search", { paused: true });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ equal(msg.result[0].id, id, "download.id is correct");
+ equal(msg.result[0].state, "interrupted", "download.state is correct");
+ equal(msg.result[0].paused, true, "download.paused is correct");
+ equal(
+ msg.result[0].estimatedEndTime,
+ null,
+ "download.estimatedEndTime is correct"
+ );
+ equal(msg.result[0].canResume, true, "download.canResume is correct");
+ equal(msg.result[0].error, "USER_CANCELED", "download.error is correct");
+ equal(
+ msg.result[0].bytesReceived,
+ INT_PARTIAL_LEN,
+ "download.bytesReceived is correct"
+ );
+ equal(
+ msg.result[0].totalBytes,
+ INT_TOTAL_LEN,
+ "download.totalBytes is correct"
+ );
+ equal(msg.result[0].exists, false, "download.exists is correct");
+
+ msg = await runInExtension("search", { error: "USER_CANCELED" });
+ equal(msg.status, "success", "search() succeeded");
+ let found = msg.result.filter(item => item.id == id);
+ equal(found.length, 1, "search() by error found the paused download");
+
+ msg = await runInExtension("cancel", id);
+ equal(msg.status, "success", "cancel() succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ id,
+ paused: {
+ previous: true,
+ current: false,
+ },
+ canResume: {
+ previous: true,
+ current: false,
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onChanged event for cancel");
+
+ msg = await runInExtension("search", { id });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ equal(msg.result[0].state, "interrupted", "download.state is correct");
+ equal(msg.result[0].paused, false, "download.paused is correct");
+ equal(
+ msg.result[0].estimatedEndTime,
+ null,
+ "download.estimatedEndTime is correct"
+ );
+ equal(msg.result[0].canResume, false, "download.canResume is correct");
+ equal(msg.result[0].error, "USER_CANCELED", "download.error is correct");
+ equal(
+ msg.result[0].totalBytes,
+ INT_TOTAL_LEN,
+ "download.totalBytes is correct"
+ );
+ equal(msg.result[0].exists, false, "download.exists is correct");
+});
+
+add_task(async function test_pause_resume_cancel_badargs() {
+ let BAD_ID = 1000;
+
+ let msg = await runInExtension("pause", BAD_ID);
+ equal(msg.status, "error", "pause() failed with a bad download id");
+ ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive");
+
+ msg = await runInExtension("resume", BAD_ID);
+ equal(msg.status, "error", "resume() failed with a bad download id");
+ ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive");
+
+ msg = await runInExtension("cancel", BAD_ID);
+ equal(msg.status, "error", "cancel() failed with a bad download id");
+ ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive");
+});
+
+add_task(async function test_file_removal() {
+ let msg = await runInExtension("download", { url: TXT_URL });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id, url: TXT_URL } },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "complete",
+ },
+ },
+ },
+ ]);
+
+ equal(msg.status, "success", "got onCreated and onChanged events");
+
+ msg = await runInExtension("removeFile", id);
+ equal(msg.status, "success", "removeFile() succeeded");
+
+ msg = await runInExtension("removeFile", id);
+ equal(
+ msg.status,
+ "error",
+ "removeFile() fails since the file was already removed."
+ );
+ equal(
+ msg.errmsg,
+ `Could not remove download id ${id} because the file doesn't exist`,
+ "removeFile() failed on removed file."
+ );
+
+ msg = await runInExtension("removeFile", 1000);
+ equal(
+ msg.errmsg,
+ "Invalid download id 1000",
+ "removeFile() failed due to non-existent id"
+ );
+});
+
+add_task(async function test_file_removeFile_permission_failure() {
+ const inputDirname = "subdir_for_download";
+ const inputFilename = "downloaded_filename.txt";
+ const expectedDir = PathUtils.join(downloadDir.path, inputDirname);
+ const expectedPath = PathUtils.join(expectedDir, inputFilename);
+
+ let msg = await runInExtension("download", {
+ url: TXT_URL,
+ filename: `${inputDirname}/${inputFilename}`,
+ });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id, url: TXT_URL } },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "complete",
+ },
+ },
+ },
+ ]);
+
+ equal(msg.status, "success", "got onCreated and onChanged events");
+
+ msg = await runInExtension("search", { id });
+ equal(msg.status, "success", "search() succeeded");
+ equal(expectedPath, msg.result[0]?.filename, "Got expected filename");
+
+ async function withUndeletableFileUnix(testRemoveFile) {
+ try {
+ // Temporarily make directory unreadable/inaccessible.
+ await IOUtils.setPermissions(expectedDir, 0);
+ // Remove should fail with Unix error 13 (EACCES).
+ await testRemoveFile();
+ } finally {
+ await IOUtils.setPermissions(expectedDir, 0o777);
+ }
+ }
+ async function withUndeletableFileWin(testRemoveFile) {
+ // On Windows, a directory marked as read-only does not prevent the deletion
+ // of its content. So we need an alternative approach here.
+ let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ try {
+ // Open a file handle. The file cannot be deleted until it is closed.
+ stream.init(await IOUtils.getFile(expectedPath), -1, 0, 0);
+ // Remove should fail with Win error 32 (ERROR_SHARING_VIOLATION).
+ await testRemoveFile();
+ } finally {
+ stream.close();
+ }
+ }
+
+ const withUndeletableFile =
+ AppConstants.platform === "win"
+ ? withUndeletableFileWin
+ : withUndeletableFileUnix;
+
+ let consoleOutput;
+ await withUndeletableFile(async () => {
+ consoleOutput = await promiseConsoleOutput(async () => {
+ msg = await runInExtension("removeFile", id);
+ });
+ });
+
+ equal(msg.status, "error", "removeFile() fails due to missing dir perms");
+ // Verify that an unexpected error is redacted, with a useful error message
+ // logged to the console.
+ // Note: if we ever decide to make the error for permission failures more
+ // useful, try to add a new test case for unexpected errors, even if
+ // completely artificial such as mocking + breaking an internal API.
+ equal(msg.errmsg, "An unexpected error occurred", "Error message redacted");
+
+ AddonTestUtils.checkMessages(consoleOutput.messages, {
+ expected: [{ message: /NotAllowedError/ }],
+ });
+
+ ok(await IOUtils.exists(expectedPath), "File exists before removeFile()");
+
+ msg = await runInExtension("removeFile", id);
+ equal(msg.status, "success", "removeFile() succeeded");
+
+ equal(await IOUtils.exists(expectedPath), false, "File was really removed");
+
+ // As a bonus: check that the re-created file can be deleted without issues.
+ await IOUtils.writeUTF8(expectedPath, "content here");
+
+ msg = await runInExtension("removeFile", id);
+ equal(msg.status, "success", "removeFile() succeeded after recreation");
+
+ equal(await IOUtils.exists(expectedPath), false, "File was removed again");
+});
+
+add_task(async function test_removal_of_incomplete_download() {
+ const filename = "remove-incomplete.html";
+ let url = getInterruptibleUrl(filename);
+ let msg = await runInExtension("download", { url });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN);
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id } },
+ ]);
+ equal(msg.status, "success", "got created and changed events");
+
+ await progressPromise;
+ info(`download reached ${INT_PARTIAL_LEN} bytes`);
+
+ // Prevent intermittent timeouts due to the part file not yet created
+ // (e.g. see Bug 1573360).
+ await waitForCreatedPartFile(filename);
+
+ msg = await runInExtension("pause", id);
+ equal(msg.status, "success", "pause() succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "interrupted",
+ },
+ paused: {
+ previous: false,
+ current: true,
+ },
+ canResume: {
+ previous: false,
+ current: true,
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ error: {
+ previous: null,
+ current: "USER_CANCELED",
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onChanged event corresponding to pause");
+
+ msg = await runInExtension("removeFile", id);
+ equal(msg.status, "error", "removeFile() on paused download failed");
+
+ ok(
+ /Cannot remove incomplete download/.test(msg.errmsg),
+ "removeFile() failed due to download being incomplete"
+ );
+
+ msg = await runInExtension("resume", id);
+ equal(msg.status, "success", "resume() succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "interrupted",
+ current: "in_progress",
+ },
+ paused: {
+ previous: true,
+ current: false,
+ },
+ canResume: {
+ previous: true,
+ current: false,
+ },
+ error: {
+ previous: "USER_CANCELED",
+ current: null,
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "complete",
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onChanged events for resume and complete");
+
+ msg = await runInExtension("removeFile", id);
+ equal(
+ msg.status,
+ "success",
+ "removeFile() succeeded following completion of resumed download."
+ );
+});
+
+// Test erase(). We don't do elaborate testing of the query handling
+// since it uses the exact same engine as search() which is tested
+// more thoroughly in test_chrome_ext_downloads_search.html
+add_task(async function test_erase() {
+ await clearDownloads();
+
+ await runInExtension("clearEvents");
+
+ async function download() {
+ let msg = await runInExtension("download", { url: TXT_URL });
+ equal(msg.status, "success", "download succeeded");
+ let id = msg.result;
+
+ msg = await runInExtension(
+ "waitForEvents",
+ [
+ {
+ type: "onChanged",
+ data: { id, state: { current: "complete" } },
+ },
+ ],
+ { exact: false }
+ );
+ equal(msg.status, "success", "download finished");
+
+ return id;
+ }
+
+ let ids = {};
+ ids.dl1 = await download();
+ ids.dl2 = await download();
+ ids.dl3 = await download();
+
+ let msg = await runInExtension("search", {});
+ equal(msg.status, "success", "search succeeded");
+ equal(msg.result.length, 3, "search found 3 downloads");
+
+ msg = await runInExtension("clearEvents");
+
+ msg = await runInExtension("erase", { id: ids.dl1 });
+ equal(msg.status, "success", "erase by id succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onErased", data: ids.dl1 },
+ ]);
+ equal(msg.status, "success", "received onErased event");
+
+ msg = await runInExtension("search", {});
+ equal(msg.status, "success", "search succeeded");
+ equal(msg.result.length, 2, "search found 2 downloads");
+
+ msg = await runInExtension("erase", {});
+ equal(msg.status, "success", "erase everything succeeded");
+
+ msg = await runInExtension(
+ "waitForEvents",
+ [
+ { type: "onErased", data: ids.dl2 },
+ { type: "onErased", data: ids.dl3 },
+ ],
+ { inorder: false }
+ );
+ equal(msg.status, "success", "received 2 onErased events");
+
+ msg = await runInExtension("search", {});
+ equal(msg.status, "success", "search succeeded");
+ equal(msg.result.length, 0, "search found 0 downloads");
+});
+
+function loadImage(img, data) {
+ return new Promise(resolve => {
+ img.src = data;
+ img.onload = resolve;
+ });
+}
+
+add_task(async function test_getFileIcon() {
+ let webNav = Services.appShell.createWindowlessBrowser(false);
+ let docShell = webNav.docShell;
+
+ let system = Services.scriptSecurityManager.getSystemPrincipal();
+ docShell.createAboutBlankContentViewer(system, system);
+
+ let img = webNav.document.createElement("img");
+
+ let msg = await runInExtension("download", { url: TXT_URL });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ msg = await runInExtension("getFileIcon", id);
+ equal(msg.status, "success", "getFileIcon() succeeded");
+ await loadImage(img, msg.result);
+ equal(img.height, 32, "returns an icon with the right height");
+ equal(img.width, 32, "returns an icon with the right width");
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id, url: TXT_URL } },
+ { type: "onChanged" },
+ ]);
+ equal(msg.status, "success", "got events");
+
+ msg = await runInExtension("getFileIcon", id);
+ equal(msg.status, "success", "getFileIcon() succeeded");
+ await loadImage(img, msg.result);
+ equal(img.height, 32, "returns an icon with the right height after download");
+ equal(img.width, 32, "returns an icon with the right width after download");
+
+ msg = await runInExtension("getFileIcon", id + 100);
+ equal(msg.status, "error", "getFileIcon() failed");
+ ok(msg.errmsg.includes("Invalid download id"), "download id is invalid");
+
+ msg = await runInExtension("getFileIcon", id, { size: 127 });
+ equal(msg.status, "success", "getFileIcon() succeeded");
+ await loadImage(img, msg.result);
+ equal(img.height, 127, "returns an icon with the right custom height");
+ equal(img.width, 127, "returns an icon with the right custom width");
+
+ msg = await runInExtension("getFileIcon", id, { size: 1 });
+ equal(msg.status, "success", "getFileIcon() succeeded");
+ await loadImage(img, msg.result);
+ equal(img.height, 1, "returns an icon with the right custom height");
+ equal(img.width, 1, "returns an icon with the right custom width");
+
+ msg = await runInExtension("getFileIcon", id, { size: "foo" });
+ equal(msg.status, "error", "getFileIcon() fails");
+ ok(msg.errmsg.includes("Error processing size"), "size is not a number");
+
+ msg = await runInExtension("getFileIcon", id, { size: 0 });
+ equal(msg.status, "error", "getFileIcon() fails");
+ ok(msg.errmsg.includes("Error processing size"), "size is too small");
+
+ msg = await runInExtension("getFileIcon", id, { size: 128 });
+ equal(msg.status, "error", "getFileIcon() fails");
+ ok(msg.errmsg.includes("Error processing size"), "size is too big");
+
+ webNav.close();
+});
+
+add_task(async function test_estimatedendtime() {
+ // Note we are not testing the actual value calculation of estimatedEndTime,
+ // only whether it is null/non-null at the appropriate times.
+
+ let url = `${getInterruptibleUrl()}&stream=1`;
+ let msg = await runInExtension("download", { url });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ let previousBytes = await waitForProgress(url, bytes => bytes > 0);
+ await waitForProgress(url, bytes => bytes > previousBytes);
+
+ msg = await runInExtension("search", { id });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ ok(msg.result[0].estimatedEndTime, "download.estimatedEndTime is correct");
+ ok(msg.result[0].bytesReceived > 0, "download.bytesReceived is correct");
+
+ msg = await runInExtension("cancel", id);
+
+ msg = await runInExtension("search", { id });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ ok(!msg.result[0].estimatedEndTime, "download.estimatedEndTime is correct");
+});
+
+add_task(async function test_byExtension() {
+ let msg = await runInExtension("download", { url: TXT_URL });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+ msg = await runInExtension("search", { id });
+
+ equal(msg.result.length, 1, "search() found 1 download");
+ equal(
+ msg.result[0].byExtensionName,
+ "Generated extension",
+ "download.byExtensionName is correct"
+ );
+ equal(
+ msg.result[0].byExtensionId,
+ extension.id,
+ "download.byExtensionId is correct"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_partitionKey.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_partitionKey.js
new file mode 100644
index 0000000000..3326ed0ce9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_partitionKey.js
@@ -0,0 +1,199 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { Downloads } = ChromeUtils.importESModule(
+ "resource://gre/modules/Downloads.sys.mjs"
+);
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE = `http://localhost:${server.identity.primaryPort}/data`;
+const TEST_FILE = "file_download.txt";
+const TEST_URL = BASE + "/" + TEST_FILE;
+
+// We use different cookieBehaviors so that we can verify if we use the correct
+// cookieBehavior if option.incognito is set. Note that we need to set a
+// non-default value to the private cookieBehavior because the private
+// cookieBehavior will mirror the regular cookieBehavior if the private pref is
+// default value and the regular pref is non-default value. To avoid affecting
+// the test by mirroring, we set the private cookieBehavior to a non-default
+// value.
+const TEST_REGULAR_COOKIE_BEHAVIOR =
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER;
+const TEST_PRIVATE_COOKIE_BEHAVIOR = Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN;
+
+let downloadDir;
+
+function observeDownloadChannel(uri, partitionKey, isPrivate) {
+ return new Promise(resolve => {
+ let observer = {
+ observe(subject, topic, data) {
+ if (topic === "http-on-modify-request") {
+ let httpChannel = subject.QueryInterface(Ci.nsIHttpChannel);
+ if (httpChannel.URI.spec != uri) {
+ return;
+ }
+
+ let reqLoadInfo = httpChannel.loadInfo;
+ let cookieJarSettings = reqLoadInfo.cookieJarSettings;
+
+ // Check the partitionKey of the cookieJarSettings.
+ equal(
+ cookieJarSettings.partitionKey,
+ partitionKey,
+ "The loadInfo has the correct paritionKey"
+ );
+
+ // Check the cookieBehavior of the cookieJarSettings.
+ equal(
+ cookieJarSettings.cookieBehavior,
+ isPrivate
+ ? TEST_PRIVATE_COOKIE_BEHAVIOR
+ : TEST_REGULAR_COOKIE_BEHAVIOR,
+ "The loadInfo has the correct cookieBehavior"
+ );
+
+ Services.obs.removeObserver(observer, "http-on-modify-request");
+ resolve();
+ }
+ },
+ };
+
+ Services.obs.addObserver(observer, "http-on-modify-request");
+ });
+}
+
+async function waitForDownloads() {
+ let list = await Downloads.getList(Downloads.ALL);
+ let downloads = await list.getAll();
+
+ let inprogress = downloads.filter(dl => !dl.stopped);
+ return Promise.all(inprogress.map(dl => dl.whenSucceeded()));
+}
+
+function backgroundScript() {
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ if (msg == "download.request") {
+ let options = args[0];
+
+ try {
+ let id = await browser.downloads.download(options);
+ browser.test.sendMessage("download.done", { status: "success", id });
+ } catch (error) {
+ browser.test.sendMessage("download.done", {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ }
+ });
+
+ browser.test.sendMessage("ready");
+}
+
+// Remove a file in the downloads directory.
+function remove(filename, recursive = false) {
+ let file = downloadDir.clone();
+ file.append(filename);
+ file.remove(recursive);
+}
+
+add_task(function setup() {
+ downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ info(`Using download directory ${downloadDir.path}`);
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue(
+ "browser.download.dir",
+ Ci.nsIFile,
+ downloadDir
+ );
+ Services.prefs.setBoolPref("privacy.partition.network_state", true);
+
+ Services.prefs.setIntPref(
+ "network.cookie.cookieBehavior",
+ TEST_REGULAR_COOKIE_BEHAVIOR
+ );
+ Services.prefs.setIntPref(
+ "network.cookie.cookieBehavior.pbmode",
+ TEST_PRIVATE_COOKIE_BEHAVIOR
+ );
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ Services.prefs.clearUserPref("privacy.partition.network_state");
+ Services.prefs.clearUserPref("network.cookie.cookieBehavior");
+ Services.prefs.clearUserPref("network.cookie.cookieBehavior.pbmode");
+
+ let entries = downloadDir.directoryEntries;
+ while (entries.hasMoreElements()) {
+ let entry = entries.nextFile;
+ info(`Leftover file ${entry.path} in download directory`);
+ ok(false, `Leftover file ${entry.path} in download directory`);
+ entry.remove(false);
+ }
+
+ downloadDir.remove(false);
+ });
+});
+
+add_task(async function test() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})()`,
+ manifest: {
+ permissions: ["downloads"],
+ },
+ incognitoOverride: "spanning",
+ });
+
+ function download(options) {
+ extension.sendMessage("download.request", options);
+ return extension.awaitMessage("download.done");
+ }
+
+ async function testDownload(url, partitionKey, isPrivate) {
+ let options = { url, incognito: isPrivate };
+
+ let promiseObserveDownloadChannel = observeDownloadChannel(
+ url,
+ partitionKey,
+ isPrivate
+ );
+
+ let msg = await download(options);
+ equal(msg.status, "success", `downloads.download() works`);
+
+ await promiseObserveDownloadChannel;
+ await waitForDownloads();
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ info("extension started");
+
+ // Call download() to check partitionKey of the download channel for the
+ // regular browsing mode.
+ await testDownload(
+ TEST_URL,
+ `(http,localhost,${server.identity.primaryPort})`,
+ false
+ );
+ remove(TEST_FILE);
+
+ // Call download again for the private browsing mode.
+ await testDownload(
+ TEST_URL,
+ `(http,localhost,${server.identity.primaryPort})`,
+ true
+ );
+ remove(TEST_FILE);
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js
new file mode 100644
index 0000000000..caf664fb86
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js
@@ -0,0 +1,306 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE = `http://localhost:${server.identity.primaryPort}/data`;
+const TXT_FILE = "file_download.txt";
+const TXT_URL = BASE + "/" + TXT_FILE;
+
+add_task(function setup() {
+ let downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ info(`Using download directory ${downloadDir.path}`);
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue(
+ "browser.download.dir",
+ Ci.nsIFile,
+ downloadDir
+ );
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+
+ let entries = downloadDir.directoryEntries;
+ while (entries.hasMoreElements()) {
+ let entry = entries.nextFile;
+ ok(false, `Leftover file ${entry.path} in download directory`);
+ entry.remove(false);
+ }
+
+ downloadDir.remove(false);
+ });
+});
+
+add_task(async function test_private_download() {
+ let pb_extension = ExtensionTestUtils.loadExtension({
+ background: async function () {
+ function promiseEvent(eventTarget, accept) {
+ return new Promise(resolve => {
+ eventTarget.addListener(function listener(data) {
+ if (accept && !accept(data)) {
+ return;
+ }
+ eventTarget.removeListener(listener);
+ resolve(data);
+ });
+ });
+ }
+ let startTestPromise = promiseEvent(browser.test.onMessage);
+ let removeTestPromise = promiseEvent(
+ browser.test.onMessage,
+ msg => msg == "remove"
+ );
+ let onCreatedPromise = promiseEvent(browser.downloads.onCreated);
+ let onDonePromise = promiseEvent(
+ browser.downloads.onChanged,
+ delta => delta.state && delta.state.current === "complete"
+ );
+
+ browser.test.sendMessage("ready");
+ let { url, filename } = await startTestPromise;
+
+ browser.test.log("Starting private download");
+ let downloadId = await browser.downloads.download({
+ url,
+ filename,
+ incognito: true,
+ });
+ browser.test.sendMessage("downloadId", downloadId);
+
+ browser.test.log("Waiting for downloads.onCreated");
+ let createdItem = await onCreatedPromise;
+
+ browser.test.log("Waiting for completion notification");
+ await onDonePromise;
+
+ // test_ext_downloads_download.js already tests whether the file exists
+ // in the file system. Here we will only verify that the downloads API
+ // behaves in a meaningful way.
+
+ let [downloadItem] = await browser.downloads.search({ id: downloadId });
+ browser.test.assertEq(url, createdItem.url, "onCreated url should match");
+ browser.test.assertEq(url, downloadItem.url, "download url should match");
+ browser.test.assertTrue(
+ createdItem.incognito,
+ "created download should be private"
+ );
+ browser.test.assertTrue(
+ downloadItem.incognito,
+ "stored download should be private"
+ );
+
+ await removeTestPromise;
+ browser.test.log("Removing downloaded file");
+ browser.test.assertTrue(downloadItem.exists, "downloaded file exists");
+ await browser.downloads.removeFile(downloadId);
+
+ // Disabled because the assertion fails - https://bugzil.la/1381031
+ // let [downloadItem2] = await browser.downloads.search({id: downloadId});
+ // browser.test.assertFalse(downloadItem2.exists, "file should be deleted");
+
+ browser.test.log("Erasing private download from history");
+ let erasePromise = promiseEvent(browser.downloads.onErased);
+ await browser.downloads.erase({ id: downloadId });
+ browser.test.assertEq(
+ downloadId,
+ await erasePromise,
+ "onErased should be fired for the erased private download"
+ );
+
+ browser.test.notifyPass("private download test done");
+ },
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@spanning" } },
+ permissions: ["downloads"],
+ },
+ incognitoOverride: "spanning",
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@not_allowed" } },
+ permissions: ["downloads", "downloads.open"],
+ },
+ background: async function () {
+ browser.downloads.onCreated.addListener(() => {
+ browser.test.fail("download-onCreated");
+ });
+ browser.downloads.onChanged.addListener(() => {
+ browser.test.fail("download-onChanged");
+ });
+ browser.downloads.onErased.addListener(() => {
+ browser.test.fail("download-onErased");
+ });
+ browser.test.onMessage.addListener(async (msg, data) => {
+ if (msg == "download") {
+ let { url, filename, downloadId } = data;
+ await browser.test.assertRejects(
+ browser.downloads.download({
+ url,
+ filename,
+ incognito: true,
+ }),
+ /private browsing access not allowed/,
+ "cannot download using incognito without permission."
+ );
+
+ let downloads = await browser.downloads.search({ id: downloadId });
+ browser.test.assertEq(
+ downloads.length,
+ 0,
+ "cannot search for incognito downloads"
+ );
+ let erasing = await browser.downloads.erase({ id: downloadId });
+ browser.test.assertEq(
+ erasing.length,
+ 0,
+ "cannot erase incognito download"
+ );
+
+ await browser.test.assertRejects(
+ browser.downloads.removeFile(downloadId),
+ /Invalid download id/,
+ "cannot remove incognito download"
+ );
+ await browser.test.assertRejects(
+ browser.downloads.pause(downloadId),
+ /Invalid download id/,
+ "cannot pause incognito download"
+ );
+ await browser.test.assertRejects(
+ browser.downloads.resume(downloadId),
+ /Invalid download id/,
+ "cannot resume incognito download"
+ );
+ await browser.test.assertRejects(
+ browser.downloads.cancel(downloadId),
+ /Invalid download id/,
+ "cannot cancel incognito download"
+ );
+ await browser.test.assertRejects(
+ browser.downloads.removeFile(downloadId),
+ /Invalid download id/,
+ "cannot remove incognito download"
+ );
+ await browser.test.assertRejects(
+ browser.downloads.show(downloadId),
+ /Invalid download id/,
+ "cannot show incognito download"
+ );
+ await browser.test.assertRejects(
+ browser.downloads.getFileIcon(downloadId),
+ /Invalid download id/,
+ "cannot show incognito download"
+ );
+ }
+ if (msg == "download.open") {
+ let { downloadId } = data;
+ await browser.test.assertRejects(
+ browser.downloads.open(downloadId),
+ /Invalid download id/,
+ "cannot open incognito download"
+ );
+ }
+ browser.test.sendMessage("continue");
+ });
+ },
+ });
+
+ await extension.startup();
+ await pb_extension.startup();
+ await pb_extension.awaitMessage("ready");
+ pb_extension.sendMessage({
+ url: TXT_URL,
+ filename: TXT_FILE,
+ });
+ let downloadId = await pb_extension.awaitMessage("downloadId");
+ extension.sendMessage("download", {
+ url: TXT_URL,
+ filename: TXT_FILE,
+ downloadId,
+ });
+ await extension.awaitMessage("continue");
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("download.open", { downloadId });
+ await extension.awaitMessage("continue");
+ });
+ pb_extension.sendMessage("remove");
+
+ await pb_extension.awaitFinish("private download test done");
+ await pb_extension.unload();
+ await extension.unload();
+});
+
+// Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1649463
+add_task(async function download_blob_in_perma_private_browsing() {
+ Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true);
+
+ // This script creates a blob:-URL and checks that the URL can be downloaded.
+ async function testScript() {
+ const blobUrl = URL.createObjectURL(new Blob(["data here"]));
+ const downloadId = await new Promise(resolve => {
+ browser.downloads.onChanged.addListener(delta => {
+ browser.test.log(`downloads.onChanged = ${JSON.stringify(delta)}`);
+ if (delta.state && delta.state.current !== "in_progress") {
+ resolve(delta.id);
+ }
+ });
+ browser.downloads.download({
+ url: blobUrl,
+ filename: "some-blob-download.txt",
+ });
+ });
+
+ let [downloadItem] = await browser.downloads.search({ id: downloadId });
+ browser.test.log(`Downloaded ${JSON.stringify(downloadItem)}`);
+ browser.test.assertEq(downloadItem.url, blobUrl, "expected blob URL");
+ // TODO bug 1653636: should be true because of perma-private browsing.
+ // browser.test.assertTrue(downloadItem.incognito, "download is private");
+ browser.test.assertFalse(
+ downloadItem.incognito,
+ "download is private [skipped - to be fixed in bug 1653636]"
+ );
+ browser.test.assertTrue(downloadItem.exists, "download exists");
+ await browser.downloads.removeFile(downloadId);
+
+ browser.test.sendMessage("downloadDone");
+ }
+ let pb_extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@private-download-ext" } },
+ permissions: ["downloads"],
+ },
+ background: testScript,
+ incognitoOverride: "spanning",
+ files: {
+ "test_part2.html": `
+ <!DOCTYPE html><meta charset="utf-8">
+ <script src="test_part2.js"></script>
+ `,
+ "test_part2.js": testScript,
+ },
+ });
+ await pb_extension.startup();
+
+ info("Testing download of blob:-URL from extension's background page");
+ await pb_extension.awaitMessage("downloadDone");
+
+ info("Testing download of blob:-URL with different userContextId");
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${pb_extension.uuid}/test_part2.html`,
+ { extension: pb_extension, userContextId: 2 }
+ );
+ await pb_extension.awaitMessage("downloadDone");
+ await contentPage.close();
+
+ await pb_extension.unload();
+ Services.prefs.clearUserPref("browser.privatebrowsing.autostart");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js
new file mode 100644
index 0000000000..37c497a9b6
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js
@@ -0,0 +1,682 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { Downloads } = ChromeUtils.importESModule(
+ "resource://gre/modules/Downloads.sys.mjs"
+);
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE = `http://localhost:${server.identity.primaryPort}/data`;
+const TXT_FILE = "file_download.txt";
+const TXT_URL = BASE + "/" + TXT_FILE;
+const TXT_LEN = 46;
+const HTML_FILE = "file_download.html";
+const HTML_URL = BASE + "/" + HTML_FILE;
+const HTML_LEN = 117;
+const EMPTY_FILE = "empty_file_download.txt";
+const EMPTY_URL = BASE + "/" + EMPTY_FILE;
+const EMPTY_LEN = 0;
+const BIG_LEN = 1000; // something bigger both TXT_LEN and HTML_LEN
+
+function backgroundScript() {
+ let complete = new Map();
+
+ function waitForComplete(id) {
+ if (complete.has(id)) {
+ return complete.get(id).promise;
+ }
+
+ let promise = new Promise(resolve => {
+ complete.set(id, { resolve });
+ });
+ complete.get(id).promise = promise;
+ return promise;
+ }
+
+ browser.downloads.onChanged.addListener(change => {
+ if (change.state && change.state.current == "complete") {
+ // Make sure we have a promise.
+ waitForComplete(change.id);
+ complete.get(change.id).resolve();
+ }
+ });
+
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ if (msg == "download.request") {
+ try {
+ let id = await browser.downloads.download(args[0]);
+ browser.test.sendMessage("download.done", { status: "success", id });
+ } catch (error) {
+ browser.test.sendMessage("download.done", {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ } else if (msg == "search.request") {
+ try {
+ let downloads = await browser.downloads.search(args[0]);
+ browser.test.sendMessage("search.done", {
+ status: "success",
+ downloads,
+ });
+ } catch (error) {
+ browser.test.sendMessage("search.done", {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ } else if (msg == "waitForComplete.request") {
+ await waitForComplete(args[0]);
+ browser.test.sendMessage("waitForComplete.done");
+ }
+ });
+
+ browser.test.sendMessage("ready");
+}
+
+async function clearDownloads(callback) {
+ let list = await Downloads.getList(Downloads.ALL);
+ let downloads = await list.getAll();
+
+ await Promise.all(downloads.map(download => list.remove(download)));
+
+ return downloads;
+}
+
+add_task(async function test_search() {
+ const nsIFile = Ci.nsIFile;
+ let downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`downloadDir ${downloadDir.path}`);
+
+ function downloadPath(filename) {
+ let path = downloadDir.clone();
+ path.append(filename);
+ return path.path;
+ }
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir);
+ Services.prefs.setBoolPref("privacy.reduceTimerPrecision", false);
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ Services.prefs.clearUserPref("privacy.reduceTimerPrecision");
+ await cleanupDir(downloadDir);
+ await clearDownloads();
+ });
+
+ await clearDownloads().then(downloads => {
+ info(`removed ${downloads.length} pre-existing downloads from history`);
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: backgroundScript,
+ manifest: {
+ permissions: ["downloads"],
+ },
+ });
+
+ async function download(options) {
+ extension.sendMessage("download.request", options);
+ let result = await extension.awaitMessage("download.done");
+
+ if (result.status == "success") {
+ info(`wait for onChanged event to indicate ${result.id} is complete`);
+ extension.sendMessage("waitForComplete.request", result.id);
+
+ await extension.awaitMessage("waitForComplete.done");
+ }
+
+ return result;
+ }
+
+ function search(query) {
+ extension.sendMessage("search.request", query);
+ return extension.awaitMessage("search.done");
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ // Do some downloads...
+ const time1 = new Date();
+
+ let downloadIds = {};
+ let msg = await download({ url: TXT_URL });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.txt1 = msg.id;
+
+ const TXT_FILE2 = "NewFile.txt";
+ msg = await download({ url: TXT_URL, filename: TXT_FILE2 });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.txt2 = msg.id;
+
+ msg = await download({ url: EMPTY_URL });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.txt3 = msg.id;
+
+ const time2 = new Date();
+
+ msg = await download({ url: HTML_URL });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.html1 = msg.id;
+
+ const HTML_FILE2 = "renamed.html";
+ msg = await download({ url: HTML_URL, filename: HTML_FILE2 });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.html2 = msg.id;
+
+ const time3 = new Date();
+
+ // Search for each individual download and check
+ // the corresponding DownloadItem.
+ async function checkDownloadItem(id, expect) {
+ let item = await search({ id });
+ equal(item.status, "success", "search() succeeded");
+ equal(item.downloads.length, 1, "search() found exactly 1 download");
+
+ Object.keys(expect).forEach(function (field) {
+ equal(
+ item.downloads[0][field],
+ expect[field],
+ `DownloadItem.${field} is correct"`
+ );
+ });
+ }
+ await checkDownloadItem(downloadIds.txt1, {
+ url: TXT_URL,
+ filename: downloadPath(TXT_FILE),
+ mime: "text/plain",
+ state: "complete",
+ bytesReceived: TXT_LEN,
+ totalBytes: TXT_LEN,
+ fileSize: TXT_LEN,
+ exists: true,
+ });
+
+ await checkDownloadItem(downloadIds.txt2, {
+ url: TXT_URL,
+ filename: downloadPath(TXT_FILE2),
+ mime: "text/plain",
+ state: "complete",
+ bytesReceived: TXT_LEN,
+ totalBytes: TXT_LEN,
+ fileSize: TXT_LEN,
+ exists: true,
+ });
+
+ await checkDownloadItem(downloadIds.txt3, {
+ url: EMPTY_URL,
+ filename: downloadPath(EMPTY_FILE),
+ mime: "text/plain",
+ state: "complete",
+ bytesReceived: EMPTY_LEN,
+ totalBytes: EMPTY_LEN,
+ fileSize: EMPTY_LEN,
+ exists: true,
+ });
+
+ await checkDownloadItem(downloadIds.html1, {
+ url: HTML_URL,
+ filename: downloadPath(HTML_FILE),
+ mime: "text/html",
+ state: "complete",
+ bytesReceived: HTML_LEN,
+ totalBytes: HTML_LEN,
+ fileSize: HTML_LEN,
+ exists: true,
+ });
+
+ await checkDownloadItem(downloadIds.html2, {
+ url: HTML_URL,
+ filename: downloadPath(HTML_FILE2),
+ mime: "text/html",
+ state: "complete",
+ bytesReceived: HTML_LEN,
+ totalBytes: HTML_LEN,
+ fileSize: HTML_LEN,
+ exists: true,
+ });
+
+ async function checkSearch(query, expected, description, exact) {
+ let item = await search(query);
+ equal(item.status, "success", "search() succeeded");
+ equal(
+ item.downloads.length,
+ expected.length,
+ `search() for ${description} found exactly ${expected.length} downloads`
+ );
+
+ let receivedIds = item.downloads.map(i => i.id);
+ if (exact) {
+ receivedIds.forEach((id, idx) => {
+ equal(
+ id,
+ downloadIds[expected[idx]],
+ `search() for ${description} returned ${expected[idx]} in position ${idx}`
+ );
+ });
+ } else {
+ Object.keys(downloadIds).forEach(key => {
+ const id = downloadIds[key];
+ const thisExpected = expected.includes(key);
+ equal(
+ receivedIds.includes(id),
+ thisExpected,
+ `search() for ${description} ${
+ thisExpected ? "includes" : "does not include"
+ } ${key}`
+ );
+ });
+ }
+ }
+
+ // Check that search with an invalid id returns nothing.
+ // NB: for now ids are not persistent and we start numbering them at 1
+ // so a sufficiently large number will be unused.
+ const INVALID_ID = 1000;
+ await checkSearch({ id: INVALID_ID }, [], "invalid id");
+
+ // Check that search on url works.
+ await checkSearch({ url: TXT_URL }, ["txt1", "txt2"], "url");
+
+ // Check that regexp on url works.
+ const HTML_REGEX = "[download]{8}.html+$";
+ await checkSearch({ urlRegex: HTML_REGEX }, ["html1", "html2"], "url regexp");
+
+ // Check that compatible url+regexp works
+ await checkSearch(
+ { url: HTML_URL, urlRegex: HTML_REGEX },
+ ["html1", "html2"],
+ "compatible url+urlRegex"
+ );
+
+ // Check that incompatible url+regexp works
+ await checkSearch(
+ { url: TXT_URL, urlRegex: HTML_REGEX },
+ [],
+ "incompatible url+urlRegex"
+ );
+
+ // Check that search on filename works.
+ await checkSearch({ filename: downloadPath(TXT_FILE) }, ["txt1"], "filename");
+
+ // Check that regexp on filename works.
+ await checkSearch({ filenameRegex: HTML_REGEX }, ["html1"], "filename regex");
+
+ // Check that compatible filename+regexp works
+ await checkSearch(
+ { filename: downloadPath(HTML_FILE), filenameRegex: HTML_REGEX },
+ ["html1"],
+ "compatible filename+filename regex"
+ );
+
+ // Check that incompatible filename+regexp works
+ await checkSearch(
+ { filename: downloadPath(TXT_FILE), filenameRegex: HTML_REGEX },
+ [],
+ "incompatible filename+filename regex"
+ );
+
+ // Check that simple positive search terms work.
+ await checkSearch(
+ { query: ["file_download"] },
+ ["txt1", "txt2", "txt3", "html1", "html2"],
+ "term file_download"
+ );
+ await checkSearch({ query: ["NewFile"] }, ["txt2"], "term NewFile");
+
+ // Check that positive search terms work case-insensitive.
+ await checkSearch({ query: ["nEwfILe"] }, ["txt2"], "term nEwfiLe");
+
+ // Check that negative search terms work.
+ await checkSearch({ query: ["-txt"] }, ["html1", "html2"], "term -txt");
+
+ // Check that positive and negative search terms together work.
+ await checkSearch(
+ { query: ["html", "-renamed"] },
+ ["html1"],
+ "positive and negative terms"
+ );
+
+ async function checkSearchWithDate(query, expected, description) {
+ const fields = Object.keys(query);
+ if (fields.length != 1 || !(query[fields[0]] instanceof Date)) {
+ throw new Error("checkSearchWithDate expects exactly one Date field");
+ }
+ const field = fields[0];
+ const date = query[field];
+
+ let newquery = {};
+
+ // Check as a Date
+ newquery[field] = date;
+ await checkSearch(newquery, expected, `${description} as Date`);
+
+ // Check as numeric milliseconds
+ newquery[field] = date.valueOf();
+ await checkSearch(newquery, expected, `${description} as numeric ms`);
+
+ // Check as stringified milliseconds
+ newquery[field] = date.valueOf().toString();
+ await checkSearch(newquery, expected, `${description} as string ms`);
+
+ // Check as ISO string
+ newquery[field] = date.toISOString();
+ await checkSearch(newquery, expected, `${description} as iso string`);
+ }
+
+ // Check startedBefore
+ await checkSearchWithDate({ startedBefore: time1 }, [], "before time1");
+ await checkSearchWithDate(
+ { startedBefore: time2 },
+ ["txt1", "txt2", "txt3"],
+ "before time2"
+ );
+ await checkSearchWithDate(
+ { startedBefore: time3 },
+ ["txt1", "txt2", "txt3", "html1", "html2"],
+ "before time3"
+ );
+
+ // Check startedAfter
+ await checkSearchWithDate(
+ { startedAfter: time1 },
+ ["txt1", "txt2", "txt3", "html1", "html2"],
+ "after time1"
+ );
+ await checkSearchWithDate(
+ { startedAfter: time2 },
+ ["html1", "html2"],
+ "after time2"
+ );
+ await checkSearchWithDate({ startedAfter: time3 }, [], "after time3");
+
+ // Check simple search on totalBytes
+ await checkSearch({ totalBytes: TXT_LEN }, ["txt1", "txt2"], "totalBytes");
+ await checkSearch({ totalBytes: HTML_LEN }, ["html1", "html2"], "totalBytes");
+
+ // Check simple test on totalBytes{Greater,Less}
+ // (NB: TXT_LEN < HTML_LEN < BIG_LEN)
+ await checkSearch(
+ { totalBytesGreater: 0 },
+ ["txt1", "txt2", "html1", "html2"],
+ "totalBytesGreater than 0"
+ );
+ await checkSearch(
+ { totalBytesGreater: TXT_LEN },
+ ["html1", "html2"],
+ `totalBytesGreater than ${TXT_LEN}`
+ );
+ await checkSearch(
+ { totalBytesGreater: HTML_LEN },
+ [],
+ `totalBytesGreater than ${HTML_LEN}`
+ );
+ await checkSearch(
+ { totalBytesLess: TXT_LEN },
+ ["txt3"],
+ `totalBytesLess than ${TXT_LEN}`
+ );
+ await checkSearch(
+ { totalBytesLess: HTML_LEN },
+ ["txt1", "txt2", "txt3"],
+ `totalBytesLess than ${HTML_LEN}`
+ );
+ await checkSearch(
+ { totalBytesLess: BIG_LEN },
+ ["txt1", "txt2", "txt3", "html1", "html2"],
+ `totalBytesLess than ${BIG_LEN}`
+ );
+
+ // Bug 1503760 check if 0 byte files with no search query are returned.
+ await checkSearch(
+ {},
+ ["txt1", "txt2", "txt3", "html1", "html2"],
+ "totalBytesGreater than -1"
+ );
+
+ // Check good combinations of totalBytes*.
+ await checkSearch(
+ { totalBytes: HTML_LEN, totalBytesGreater: TXT_LEN },
+ ["html1", "html2"],
+ "totalBytes and totalBytesGreater"
+ );
+ await checkSearch(
+ { totalBytes: TXT_LEN, totalBytesLess: HTML_LEN },
+ ["txt1", "txt2"],
+ "totalBytes and totalBytesGreater"
+ );
+ await checkSearch(
+ { totalBytes: HTML_LEN, totalBytesLess: BIG_LEN, totalBytesGreater: 0 },
+ ["html1", "html2"],
+ "totalBytes and totalBytesLess and totalBytesGreater"
+ );
+
+ // Check bad combination of totalBytes*.
+ await checkSearch(
+ { totalBytesLess: TXT_LEN, totalBytesGreater: HTML_LEN },
+ [],
+ "bad totalBytesLess, totalBytesGreater combination"
+ );
+ await checkSearch(
+ { totalBytes: TXT_LEN, totalBytesGreater: HTML_LEN },
+ [],
+ "bad totalBytes, totalBytesGreater combination"
+ );
+ await checkSearch(
+ { totalBytes: HTML_LEN, totalBytesLess: TXT_LEN },
+ [],
+ "bad totalBytes, totalBytesLess combination"
+ );
+
+ // Check mime.
+ await checkSearch(
+ { mime: "text/plain" },
+ ["txt1", "txt2", "txt3"],
+ "mime text/plain"
+ );
+ await checkSearch(
+ { mime: "text/html" },
+ ["html1", "html2"],
+ "mime text/htmlplain"
+ );
+ await checkSearch({ mime: "video/webm" }, [], "mime video/webm");
+
+ // Check fileSize.
+ await checkSearch({ fileSize: TXT_LEN }, ["txt1", "txt2"], "fileSize");
+ await checkSearch({ fileSize: HTML_LEN }, ["html1", "html2"], "fileSize");
+
+ // Fields like bytesReceived, paused, state, exists are meaningful
+ // for downloads that are in progress but have not yet completed.
+ // todo: add tests for these when we have better support for in-progress
+ // downloads (e.g., after pause(), resume() and cancel() are implemented)
+
+ // Check multiple query properties.
+ // We could make this testing arbitrarily complicated...
+ // We already tested combining fields with obvious interactions above
+ // (e.g., filename and filenameRegex or startTime and startedBefore/After)
+ // so now just throw as many fields as we can at a single search and
+ // make sure a simple case still works.
+ await checkSearch(
+ {
+ url: TXT_URL,
+ urlRegex: "download",
+ filename: downloadPath(TXT_FILE),
+ filenameRegex: "download",
+ query: ["download"],
+ startedAfter: time1.valueOf().toString(),
+ startedBefore: time2.valueOf().toString(),
+ totalBytes: TXT_LEN,
+ totalBytesGreater: 0,
+ totalBytesLess: BIG_LEN,
+ mime: "text/plain",
+ fileSize: TXT_LEN,
+ },
+ ["txt1"],
+ "many properties"
+ );
+
+ // Check simple orderBy (forward and backward).
+ await checkSearch(
+ { orderBy: ["startTime"] },
+ ["txt1", "txt2", "txt3", "html1", "html2"],
+ "orderBy startTime",
+ true
+ );
+ await checkSearch(
+ { orderBy: ["-startTime"] },
+ ["html2", "html1", "txt3", "txt2", "txt1"],
+ "orderBy -startTime",
+ true
+ );
+
+ // Check orderBy with multiple fields.
+ // NB: TXT_URL and HTML_URL differ only in extension and .html precedes .txt
+ // EMPTY_URL begins with e which precedes f
+ await checkSearch(
+ { orderBy: ["url", "-startTime"] },
+ ["txt3", "html2", "html1", "txt2", "txt1"],
+ "orderBy with multiple fields",
+ true
+ );
+
+ // Check orderBy with limit.
+ await checkSearch(
+ { orderBy: ["url"], limit: 1 },
+ ["txt3"],
+ "orderBy with limit",
+ true
+ );
+
+ // Check bad arguments.
+ async function checkBadSearch(query, pattern, description) {
+ let item = await search(query);
+ equal(item.status, "error", "search() failed");
+ ok(
+ pattern.test(item.errmsg),
+ `error message for ${description} was correct (${item.errmsg}).`
+ );
+ }
+
+ await checkBadSearch(
+ "myquery",
+ /Incorrect argument type/,
+ "query is not an object"
+ );
+ await checkBadSearch(
+ { bogus: "boo" },
+ /Unexpected property/,
+ "query contains an unknown field"
+ );
+ await checkBadSearch(
+ { query: "query string" },
+ /Expected array/,
+ "query.query is a string"
+ );
+ await checkBadSearch(
+ { startedBefore: "i am not a time" },
+ /Type error/,
+ "query.startedBefore is not a valid time"
+ );
+ await checkBadSearch(
+ { startedAfter: "i am not a time" },
+ /Type error/,
+ "query.startedAfter is not a valid time"
+ );
+ await checkBadSearch(
+ { endedBefore: "i am not a time" },
+ /Type error/,
+ "query.endedBefore is not a valid time"
+ );
+ await checkBadSearch(
+ { endedAfter: "i am not a time" },
+ /Type error/,
+ "query.endedAfter is not a valid time"
+ );
+ await checkBadSearch(
+ { urlRegex: "[" },
+ /Invalid urlRegex/,
+ "query.urlRegexp is not a valid regular expression"
+ );
+ await checkBadSearch(
+ { filenameRegex: "[" },
+ /Invalid filenameRegex/,
+ "query.filenameRegexp is not a valid regular expression"
+ );
+ await checkBadSearch(
+ { orderBy: "startTime" },
+ /Expected array/,
+ "query.orderBy is not an array"
+ );
+ await checkBadSearch(
+ { orderBy: ["bogus"] },
+ /Invalid orderBy field/,
+ "query.orderBy references a non-existent field"
+ );
+
+ await extension.unload();
+});
+
+// Test that downloads with totalBytes of -1 (ie, that have not yet started)
+// work properly. See bug 1519762 for details of a past regression in
+// this area.
+add_task(async function test_inprogress() {
+ let resume,
+ resumePromise = new Promise(resolve => {
+ resume = resolve;
+ });
+ let hit = false;
+ server.registerPathHandler("/data/slow", async (request, response) => {
+ hit = true;
+ response.processAsync();
+ await resumePromise;
+ response.setHeader("Content-type", "text/plain");
+ response.write("");
+ response.finish();
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["downloads"],
+ },
+ background() {
+ browser.test.onMessage.addListener(async (msg, url) => {
+ let id = await browser.downloads.download({ url });
+ let full = await browser.downloads.search({ id });
+
+ browser.test.assertEq(
+ full.length,
+ 1,
+ "Found new download in search results"
+ );
+ browser.test.assertEq(
+ full[0].totalBytes,
+ -1,
+ "New download still has totalBytes == -1"
+ );
+
+ browser.downloads.onChanged.addListener(info => {
+ if (info.id == id && info.state && info.state.current == "complete") {
+ browser.test.notifyPass("done");
+ }
+ });
+
+ browser.test.sendMessage("started");
+ });
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage("go", `${BASE}/slow`);
+ await extension.awaitMessage("started");
+ resume();
+ await extension.awaitFinish("done");
+ await extension.unload();
+ Assert.ok(hit, "slow path was actually hit");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js
new file mode 100644
index 0000000000..03288fb5d5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js
@@ -0,0 +1,257 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { Downloads } = ChromeUtils.importESModule(
+ "resource://gre/modules/Downloads.sys.mjs"
+);
+
+function backgroundScript() {
+ let complete = new Map();
+
+ function waitForComplete(id) {
+ if (complete.has(id)) {
+ return complete.get(id).promise;
+ }
+
+ let promise = new Promise(resolve => {
+ complete.set(id, { resolve });
+ });
+ complete.get(id).promise = promise;
+ return promise;
+ }
+
+ browser.downloads.onChanged.addListener(change => {
+ if (change.state && change.state.current == "complete") {
+ // Make sure we have a promise.
+ waitForComplete(change.id);
+ complete.get(change.id).resolve();
+ }
+ });
+
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ if (msg == "download.request") {
+ try {
+ let id = await browser.downloads.download(args[0]);
+ browser.test.sendMessage("download.done", { status: "success", id });
+ } catch (error) {
+ browser.test.sendMessage("download.done", {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ } else if (msg == "search.request") {
+ try {
+ let downloads = await browser.downloads.search(args[0]);
+ browser.test.sendMessage("search.done", {
+ status: "success",
+ downloads,
+ });
+ } catch (error) {
+ browser.test.sendMessage("search.done", {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ } else if (msg == "waitForComplete.request") {
+ await waitForComplete(args[0]);
+ browser.test.sendMessage("waitForComplete.done");
+ }
+ });
+
+ browser.test.sendMessage("ready");
+}
+
+async function clearDownloads(callback) {
+ let list = await Downloads.getList(Downloads.ALL);
+ let downloads = await list.getAll();
+
+ await Promise.all(downloads.map(download => list.remove(download)));
+
+ return downloads;
+}
+
+add_task(async function test_decoded_filename_download() {
+ const server = createHttpServer();
+ server.registerPrefixHandler("/data/", (_, res) => res.write("length=8"));
+
+ const BASE = `http://localhost:${server.identity.primaryPort}/data`;
+ const FILE_NAME_ENCODED_1 = "file%2Fencode.txt";
+ const FILE_NAME_DECODED_1 = "file_encode.txt";
+ const FILE_NAME_ENCODED_URL_1 = BASE + "/" + FILE_NAME_ENCODED_1;
+ const FILE_NAME_ENCODED_2 = "file%F0%9F%9A%B2encoded.txt";
+ const FILE_NAME_DECODED_2 = "file\u{0001F6B2}encoded.txt";
+ const FILE_NAME_ENCODED_URL_2 = BASE + "/" + FILE_NAME_ENCODED_2;
+ const FILE_NAME_ENCODED_3 = "file%X%20encode.txt";
+ const FILE_NAME_DECODED_3 = "file%X encode.txt";
+ const FILE_NAME_ENCODED_URL_3 = BASE + "/" + FILE_NAME_ENCODED_3;
+ const FILE_NAME_ENCODED_4 = "file%E3%80%82encode.txt";
+ const FILE_NAME_DECODED_4 = "file\u3002encode.txt";
+ const FILE_NAME_ENCODED_URL_4 = BASE + "/" + FILE_NAME_ENCODED_4;
+ const FILE_ENCODED_LEN = 8;
+
+ const nsIFile = Ci.nsIFile;
+ let downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`downloadDir ${downloadDir.path}`);
+
+ function downloadPath(filename) {
+ let path = downloadDir.clone();
+ path.append(filename);
+ return path.path;
+ }
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir);
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ await cleanupDir(downloadDir);
+ await clearDownloads();
+ });
+
+ await clearDownloads().then(downloads => {
+ info(`removed ${downloads.length} pre-existing downloads from history`);
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: backgroundScript,
+ manifest: {
+ permissions: ["downloads"],
+ },
+ });
+
+ async function download(options) {
+ extension.sendMessage("download.request", options);
+ let result = await extension.awaitMessage("download.done");
+
+ if (result.status == "success") {
+ info(`wait for onChanged event to indicate ${result.id} is complete`);
+ extension.sendMessage("waitForComplete.request", result.id);
+
+ await extension.awaitMessage("waitForComplete.done");
+ }
+
+ return result;
+ }
+
+ function search(query) {
+ extension.sendMessage("search.request", query);
+ return extension.awaitMessage("search.done");
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ let downloadIds = {};
+ let msg = await download({ url: FILE_NAME_ENCODED_URL_1 });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.fileEncoded1 = msg.id;
+
+ msg = await download({ url: FILE_NAME_ENCODED_URL_2 });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.fileEncoded2 = msg.id;
+
+ msg = await download({ url: FILE_NAME_ENCODED_URL_3 });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.fileEncoded3 = msg.id;
+
+ msg = await download({ url: FILE_NAME_ENCODED_URL_4 });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.fileEncoded4 = msg.id;
+
+ // Search for each individual download and check
+ // the corresponding DownloadItem.
+ async function checkDownloadItem(id, expect) {
+ let item = await search({ id });
+ equal(item.status, "success", "search() succeeded");
+ equal(item.downloads.length, 1, "search() found exactly 1 download");
+ Object.keys(expect).forEach(function (field) {
+ equal(
+ item.downloads[0][field],
+ expect[field],
+ `DownloadItem.${field} is correct"`
+ );
+ });
+ }
+
+ await checkDownloadItem(downloadIds.fileEncoded1, {
+ url: FILE_NAME_ENCODED_URL_1,
+ filename: downloadPath(FILE_NAME_DECODED_1),
+ state: "complete",
+ bytesReceived: FILE_ENCODED_LEN,
+ totalBytes: FILE_ENCODED_LEN,
+ fileSize: FILE_ENCODED_LEN,
+ exists: true,
+ });
+
+ await checkDownloadItem(downloadIds.fileEncoded2, {
+ url: FILE_NAME_ENCODED_URL_2,
+ filename: downloadPath(FILE_NAME_DECODED_2),
+ state: "complete",
+ bytesReceived: FILE_ENCODED_LEN,
+ totalBytes: FILE_ENCODED_LEN,
+ fileSize: FILE_ENCODED_LEN,
+ exists: true,
+ });
+
+ await checkDownloadItem(downloadIds.fileEncoded3, {
+ url: FILE_NAME_ENCODED_URL_3,
+ filename: downloadPath(FILE_NAME_DECODED_3),
+ state: "complete",
+ bytesReceived: FILE_ENCODED_LEN,
+ totalBytes: FILE_ENCODED_LEN,
+ fileSize: FILE_ENCODED_LEN,
+ exists: true,
+ });
+
+ await checkDownloadItem(downloadIds.fileEncoded4, {
+ url: FILE_NAME_ENCODED_URL_4,
+ filename: downloadPath(FILE_NAME_DECODED_4),
+ state: "complete",
+ bytesReceived: FILE_ENCODED_LEN,
+ totalBytes: FILE_ENCODED_LEN,
+ fileSize: FILE_ENCODED_LEN,
+ exists: true,
+ });
+
+ // Searching for downloads by the decoded filename works correctly.
+ async function checkSearch(query, expected, description) {
+ let item = await search(query);
+ equal(item.status, "success", "search() succeeded");
+ equal(
+ item.downloads.length,
+ expected.length,
+ `search() for ${description} found exactly ${expected.length} downloads`
+ );
+ equal(
+ item.downloads[0].id,
+ downloadIds[expected[0]],
+ `search() for ${description} returned ${expected[0]} in position ${0}`
+ );
+ }
+
+ await checkSearch(
+ { filename: downloadPath(FILE_NAME_DECODED_1) },
+ ["fileEncoded1"],
+ "filename"
+ );
+ await checkSearch(
+ { filename: downloadPath(FILE_NAME_DECODED_2) },
+ ["fileEncoded2"],
+ "filename"
+ );
+ await checkSearch(
+ { filename: downloadPath(FILE_NAME_DECODED_3) },
+ ["fileEncoded3"],
+ "filename"
+ );
+ await checkSearch(
+ { filename: downloadPath(FILE_NAME_DECODED_4) },
+ ["fileEncoded4"],
+ "filename"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js b/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js
new file mode 100644
index 0000000000..ab18c9c371
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js
@@ -0,0 +1,48 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_error_location() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let { fileName } = new Error();
+
+ browser.test.sendMessage("fileName", fileName);
+
+ browser.runtime.sendMessage("Meh.", () => {});
+
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage("Meh"),
+ error => {
+ return error.fileName === fileName && error.lineNumber === 9;
+ }
+ );
+
+ browser.test.notifyPass("error-location");
+ },
+ });
+
+ let fileName;
+ const { messages } = await promiseConsoleOutput(async () => {
+ await extension.startup();
+
+ fileName = await extension.awaitMessage("fileName");
+
+ await extension.awaitFinish("error-location");
+
+ await extension.unload();
+ });
+
+ let [msg] = messages.filter(m => m.message.includes("Unchecked lastError"));
+
+ equal(msg.sourceName, fileName, "Message source");
+ equal(msg.lineNumber, 6, "Message line");
+
+ let frame = msg.stack;
+ if (frame) {
+ equal(frame.source, fileName, "Frame source");
+ equal(frame.line, 6, "Frame line");
+ equal(frame.column, 23, "Frame column");
+ equal(frame.functionDisplayName, "background", "Frame function name");
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_idle.js b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_idle.js
new file mode 100644
index 0000000000..6aade7af1f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_idle.js
@@ -0,0 +1,574 @@
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionPreferencesManager:
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+Services.prefs.setBoolPref("extensions.eventPages.enabled", true);
+// Set minimum idle timeout for testing
+Services.prefs.setIntPref("extensions.background.idle.timeout", 0);
+
+// Expected rejection from the test cases defined in this file.
+PromiseTestUtils.allowMatchingRejectionsGlobally(/expected-test-rejection/);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Actor 'Conduits' destroyed before query 'RunListener' was resolved/
+);
+
+add_setup(async () => {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_eventpage_idle() {
+ clearHistograms();
+
+ assertHistogramEmpty(WEBEXT_EVENTPAGE_RUNNING_TIME_MS);
+ assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID);
+ assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT);
+ assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["browserSettings"],
+ background: { persistent: false },
+ },
+ background() {
+ browser.browserSettings.allowPopupsForUserEvents.onChange.addListener(
+ () => {
+ browser.test.sendMessage("allowPopupsForUserEvents");
+ }
+ );
+ browser.runtime.onSuspend.addListener(async () => {
+ let setting =
+ await browser.browserSettings.allowPopupsForUserEvents.get({});
+ browser.test.sendMessage("suspended", setting);
+ });
+ },
+ });
+ await extension.startup();
+ assertPersistentListeners(
+ extension,
+ "browserSettings",
+ "allowPopupsForUserEvents",
+ {
+ primed: false,
+ }
+ );
+
+ info(`test idle timeout after startup`);
+ await extension.awaitMessage("suspended");
+ await promiseExtensionEvent(extension, "shutdown-background-script");
+ assertPersistentListeners(
+ extension,
+ "browserSettings",
+ "allowPopupsForUserEvents",
+ {
+ primed: true,
+ }
+ );
+ ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "allowPopupsForUserEvents",
+ "click"
+ );
+ await extension.awaitMessage("allowPopupsForUserEvents");
+ ok(true, "allowPopupsForUserEvents.onChange fired");
+
+ // again after the event is fired
+ info(`test idle timeout after wakeup`);
+ let setting = await extension.awaitMessage("suspended");
+ equal(setting.value, true, "verify simple async wait works in onSuspend");
+
+ await promiseExtensionEvent(extension, "shutdown-background-script");
+ assertPersistentListeners(
+ extension,
+ "browserSettings",
+ "allowPopupsForUserEvents",
+ {
+ primed: true,
+ }
+ );
+ ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "allowPopupsForUserEvents",
+ false
+ );
+ await extension.awaitMessage("allowPopupsForUserEvents");
+ ok(true, "allowPopupsForUserEvents.onChange fired");
+
+ const { id } = extension;
+ await extension.unload();
+
+ info("Verify eventpage telemetry recorded");
+
+ assertHistogramSnapshot(
+ WEBEXT_EVENTPAGE_RUNNING_TIME_MS,
+ {
+ keyed: false,
+ processSnapshot: snapshot => snapshot.sum > 0,
+ expectedValue: true,
+ },
+ `Expect stored values in the eventpage running time non-keyed histogram snapshot`
+ );
+
+ assertHistogramSnapshot(
+ WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID,
+ {
+ keyed: true,
+ processSnapshot: snapshot => snapshot[id]?.sum > 0,
+ expectedValue: true,
+ },
+ `Expect stored values for addon with id ${id} in the eventpage running time keyed histogram snapshot`
+ );
+
+ assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, {
+ category: "suspend",
+ categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES,
+ });
+
+ assertHistogramCategoryNotEmpty(
+ WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID,
+ {
+ keyed: true,
+ key: id,
+ category: "suspend",
+ categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES,
+ }
+ );
+});
+
+add_task(
+ { pref_set: [["extensions.webextensions.runtime.timeout", 500]] },
+ async function test_eventpage_runtime_onSuspend_timeout() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ background: { persistent: false },
+ },
+ background() {
+ browser.runtime.onSuspend.addListener(() => {
+ // return a promise that never resolves
+ return new Promise(() => {});
+ });
+ },
+ });
+ await extension.startup();
+ await promiseExtensionEvent(extension, "shutdown-background-script");
+ ok(true, "onSuspend did not block background shutdown");
+ await extension.unload();
+ }
+);
+
+add_task(
+ { pref_set: [["extensions.webextensions.runtime.timeout", 500]] },
+ async function test_eventpage_runtime_onSuspend_reject() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ background: { persistent: false },
+ },
+ background() {
+ browser.runtime.onSuspend.addListener(() => {
+ // Raise an error to test error handling in onSuspend
+ return Promise.reject("testing reject");
+ });
+ },
+ });
+ await extension.startup();
+ await promiseExtensionEvent(extension, "shutdown-background-script");
+ ok(true, "onSuspend did not block background shutdown");
+ await extension.unload();
+ }
+);
+
+add_task(
+ { pref_set: [["extensions.webextensions.runtime.timeout", 1000]] },
+ async function test_eventpage_runtime_onSuspend_canceled() {
+ clearHistograms();
+
+ assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT);
+ assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["browserSettings"],
+ background: { persistent: false },
+ },
+ background() {
+ let resolveSuspend;
+ browser.browserSettings.allowPopupsForUserEvents.onChange.addListener(
+ () => {
+ browser.test.sendMessage("allowPopupsForUserEvents");
+ }
+ );
+ browser.runtime.onSuspend.addListener(() => {
+ browser.test.sendMessage("suspending");
+ return new Promise(resolve => {
+ resolveSuspend = resolve;
+ });
+ });
+ browser.runtime.onSuspendCanceled.addListener(() => {
+ browser.test.sendMessage("suspendCanceled");
+ });
+ browser.test.onMessage.addListener(() => {
+ resolveSuspend();
+ });
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("suspending");
+ // While suspending, cause an event
+ ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "allowPopupsForUserEvents",
+ "click"
+ );
+ extension.sendMessage("resolveSuspend");
+ await extension.awaitMessage("allowPopupsForUserEvents");
+ await extension.awaitMessage("suspendCanceled");
+ ok(true, "event caused suspend-canceled");
+
+ assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, {
+ category: "reset_event",
+ categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES,
+ });
+
+ assertHistogramCategoryNotEmpty(
+ WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID,
+ {
+ keyed: true,
+ key: extension.id,
+ category: "reset_event",
+ categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES,
+ }
+ );
+
+ await extension.awaitMessage("suspending");
+ await promiseExtensionEvent(extension, "shutdown-background-script");
+ await extension.unload();
+ }
+);
+
+add_task(async function test_terminateBackground_after_extension_hasShutdown() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ background: { persistent: false },
+ },
+ async background() {
+ browser.runtime.onSuspend.addListener(() => {
+ browser.test.fail(
+ `runtime.onSuspend listener should have not been called`
+ );
+ });
+
+ // Call an API method implemented in the parent process (to be sure runtime.onSuspend
+ // listener is going to be fully registered from a parent process perspective by the
+ // time we will send the "bg-ready" test message).
+ await browser.runtime.getBrowserInfo();
+
+ browser.test.sendMessage("bg-ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bg-ready");
+
+ // Fake suspending event page on idle while the extension was being shutdown by manually
+ // setting the hasShutdown flag to true on the Extension class instance object.
+ extension.extension.hasShutdown = true;
+ await extension.terminateBackground();
+ extension.extension.hasShutdown = false;
+
+ await extension.unload();
+});
+
+add_task(async function test_wakeupBackground_after_extension_hasShutdown() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ background: { persistent: false },
+ },
+ async background() {
+ browser.test.sendMessage("bg-ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bg-ready");
+ await extension.terminateBackground();
+
+ // Fake suspending event page on idle while the extension was being shutdown by manually
+ // setting the hasShutdown flag to true on the Extension class instance object.
+ extension.extension.hasShutdown = true;
+ await Assert.rejects(
+ extension.wakeupBackground(),
+ /wakeupBackground called while the extension was already shutting down/,
+ "Got the expected rejection when wakeupBackground is called after extension shutdown"
+ );
+ extension.extension.hasShutdown = false;
+
+ await extension.unload();
+});
+
+async function testSuspendShutdownRace({ manifest_version }) {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ background: manifest_version === 2 ? { persistent: false } : {},
+ permissions: ["webRequest", "webRequestBlocking"],
+ host_permissions: ["*://example.com/*"],
+ granted_host_permissions: true,
+ },
+ // Define an empty background script.
+ background() {},
+ });
+
+ await extension.startup();
+ await extension.extension.promiseBackgroundStarted();
+ const promiseTerminateBackground = extension.extension.terminateBackground();
+ // Wait one tick to leave to terminateBackground async method time to get
+ // past the first check that returns earlier if extension.hasShutdown is true.
+ await Promise.resolve();
+ const promiseUnload = extension.unload();
+
+ await promiseUnload;
+ try {
+ await promiseTerminateBackground;
+ ok(true, "extension.terminateBackground should not have been rejected");
+ } catch (err) {
+ ok(
+ false,
+ `extension.terminateBackground should not have been rejected: ${err} :: ${err.stack}`
+ );
+ }
+}
+
+add_task(function test_mv2_suspend_shutdown_race() {
+ return testSuspendShutdownRace({ manifest_version: 2 });
+});
+
+add_task(
+ {
+ pref_set: [["extensions.manifestV3.enabled", true]],
+ },
+ function test_mv3_suspend_shutdown_race() {
+ return testSuspendShutdownRace({ manifest_version: 3 });
+ }
+);
+
+function createPendingListenerTestExtension() {
+ return ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["browserSettings"],
+ background: { persistent: false },
+ },
+ background() {
+ let idx = 0;
+ browser.browserSettings.allowPopupsForUserEvents.onChange.addListener(
+ async () => {
+ const currIdx = idx++;
+ await new Promise((resolve, reject) => {
+ browser.test.onMessage.addListener(msg => {
+ switch (`${msg}-${currIdx}`) {
+ case "unblock-promise-0":
+ resolve();
+ browser.test.sendMessage("allowPopupsForUserEvents:resolved");
+ break;
+ case "unblock-promise-1":
+ reject(new Error("expected-test-rejection"));
+ browser.test.sendMessage("allowPopupsForUserEvents:rejected");
+ break;
+ default:
+ browser.test.fail(`Unexpected test message: ${msg}`);
+ }
+ });
+ browser.test.sendMessage("allowPopupsForUserEvents:awaiting");
+ });
+ }
+ );
+
+ browser.runtime.onSuspend.addListener(() => {
+ // Raise an error to test error handling in onSuspend
+ return browser.test.sendMessage("runtime-on-suspend");
+ });
+
+ browser.test.sendMessage("bg-script-ready");
+ },
+ });
+}
+
+add_task(
+ { pref_set: [["extensions.background.idle.timeout", 500]] },
+ async function test_eventpage_idle_reset_on_async_listener_unresolved() {
+ clearHistograms();
+
+ assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT);
+ assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID);
+
+ let extension = createPendingListenerTestExtension();
+ await extension.startup();
+ await extension.awaitMessage("bg-script-ready");
+
+ info("Trigger the first API event listener call");
+ ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "allowPopupsForUserEvents",
+ "click"
+ );
+
+ await extension.awaitMessage("allowPopupsForUserEvents:awaiting");
+
+ info("Trigger the second API event listener call");
+ ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "allowPopupsForUserEvents",
+ "click"
+ );
+
+ await extension.awaitMessage("allowPopupsForUserEvents:awaiting");
+
+ info("Wait for suspend on idle to be reset");
+ const [, resetIdleData] = await promiseExtensionEvent(
+ extension,
+ "background-script-reset-idle"
+ );
+
+ Assert.deepEqual(
+ resetIdleData,
+ {
+ reason: "pendingListeners",
+ pendingListeners: 2,
+ },
+ "Got the expected idle reset reason and pendingListeners count"
+ );
+
+ assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, {
+ category: "reset_listeners",
+ categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES,
+ });
+
+ assertHistogramCategoryNotEmpty(
+ WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID,
+ {
+ keyed: true,
+ key: extension.id,
+ category: "reset_listeners",
+ categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES,
+ }
+ );
+
+ info(
+ "Resolve the async listener pending on a promise and expect the event page to suspend after the idle timeout"
+ );
+ extension.sendMessage("unblock-promise");
+ // Expect the two promises to be resolved and rejected respectively.
+ await extension.awaitMessage("allowPopupsForUserEvents:resolved");
+ await extension.awaitMessage("allowPopupsForUserEvents:rejected");
+
+ info("Await for the runtime.onSuspend event to be emitted");
+ await extension.awaitMessage("runtime-on-suspend");
+ await extension.unload();
+ }
+);
+
+add_task(
+ { pref_set: [["extensions.background.idle.timeout", 500]] },
+ async function test_pending_async_listeners_promises_rejected_on_shutdown() {
+ let extension = createPendingListenerTestExtension();
+ await extension.startup();
+ await extension.awaitMessage("bg-script-ready");
+
+ info("Trigger the API event listener call");
+ ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "allowPopupsForUserEvents",
+ "click"
+ );
+
+ await extension.awaitMessage("allowPopupsForUserEvents:awaiting");
+
+ const { runListenerPromises } = extension.extension.backgroundContext;
+ equal(
+ runListenerPromises.size,
+ 1,
+ "Got the expected number of pending runListener promises"
+ );
+
+ const pendingPromise = Array.from(runListenerPromises)[0];
+
+ // Shutdown the extension while there is still a pending promises being tracked
+ // to verify they gets rejected as expected when the background page browser element
+ // is going to be destroyed.
+ await extension.unload();
+
+ await Assert.rejects(
+ pendingPromise,
+ /Actor 'Conduits' destroyed before query 'RunListener' was resolved/,
+ "Previously pending runListener promise rejected with the expected error"
+ );
+
+ equal(
+ runListenerPromises.size,
+ 0,
+ "Expect no remaining pending runListener promises"
+ );
+ }
+);
+
+add_task(
+ { pref_set: [["extensions.background.idle.timeout", 500]] },
+ async function test_eventpage_idle_reset_once_on_pending_async_listeners() {
+ let extension = createPendingListenerTestExtension();
+ await extension.startup();
+ await extension.awaitMessage("bg-script-ready");
+
+ info("Trigger the API event listener call");
+ ExtensionPreferencesManager.setSetting(
+ extension.id,
+ "allowPopupsForUserEvents",
+ "click"
+ );
+
+ await extension.awaitMessage("allowPopupsForUserEvents:awaiting");
+
+ info("Wait for suspend on the first idle timeout to be reset");
+ const [, resetIdleData] = await promiseExtensionEvent(
+ extension,
+ "background-script-reset-idle"
+ );
+
+ Assert.deepEqual(
+ resetIdleData,
+ {
+ reason: "pendingListeners",
+ pendingListeners: 1,
+ },
+ "Got the expected idle reset reason and pendingListeners count"
+ );
+
+ info(
+ "Await for the runtime.onSuspend event to be emitted on the second idle timeout hit"
+ );
+ // We expect this part of the test to trigger a uncaught rejection for the
+ // "Actor 'Conduits' destroyed before query 'RunListener' was resolved" error,
+ // due to the listener left purposely pending in this test
+ // and so that expected rejection is ignored using PromiseTestUtils in the preamble
+ // of this test file.
+ await extension.awaitMessage("runtime-on-suspend");
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_settings.js b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_settings.js
new file mode 100644
index 0000000000..66a6b45020
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_settings.js
@@ -0,0 +1,166 @@
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+Services.prefs.setBoolPref("extensions.eventPages.enabled", true);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "AboutNewTab",
+ "resource:///modules/AboutNewTab.jsm"
+);
+
+add_task(async function setup() {
+ await AddonTestUtils.promiseStartupManager();
+
+ // Create an object to hold the values to which we will initialize the prefs.
+ const PREFS = {
+ "browser.cache.disk.enable": true,
+ "browser.cache.memory.enable": true,
+ };
+
+ // Set prefs to our initial values.
+ for (let pref in PREFS) {
+ Preferences.set(pref, PREFS[pref]);
+ }
+
+ registerCleanupFunction(() => {
+ // Reset the prefs.
+ for (let pref in PREFS) {
+ Preferences.reset(pref);
+ }
+ });
+});
+
+// Other tests exist for all the settings, this smoke tests that the
+// settings will startup an event page.
+add_task(async function test_browser_settings() {
+ let setExt = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["browserSettings", "privacy"],
+ },
+ background() {
+ browser.test.onMessage.addListener(async (msg, apiName, value) => {
+ let apiObj = apiName.split(".").reduce((o, i) => o[i], browser);
+ let result = await apiObj.set({ value });
+ if (msg === "set") {
+ browser.test.assertTrue(result, "set returns true.");
+ } else {
+ browser.test.assertFalse(result, "set returns false for a no-op.");
+ }
+ });
+ },
+ });
+ await setExt.startup();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["browserSettings", "privacy"],
+ background: { persistent: false },
+ },
+ background() {
+ browser.browserSettings.cacheEnabled.onChange.addListener(() => {
+ browser.test.log("cacheEnabled received");
+ browser.test.sendMessage("cacheEnabled");
+ });
+ browser.browserSettings.homepageOverride.onChange.addListener(() => {
+ browser.test.sendMessage("homepageOverride");
+ });
+ browser.browserSettings.newTabPageOverride.onChange.addListener(() => {
+ browser.test.sendMessage("newTabPageOverride");
+ });
+ browser.privacy.services.passwordSavingEnabled.onChange.addListener(
+ () => {
+ browser.test.sendMessage("passwordSavingEnabled");
+ }
+ );
+ },
+ });
+ await extension.startup();
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ assertPersistentListeners(extension, "browserSettings", "cacheEnabled", {
+ primed: true,
+ });
+
+ info(`testing cacheEnabled`);
+ setExt.sendMessage("set", "browserSettings.cacheEnabled", false);
+ await extension.awaitMessage("cacheEnabled");
+ ok(true, "cacheEnabled.onChange fired");
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ assertPersistentListeners(extension, "browserSettings", "homepageOverride", {
+ primed: true,
+ });
+
+ info(`testing homepageOverride`);
+ Preferences.set("browser.startup.homepage", "http://homepage.example.com");
+ await extension.awaitMessage("homepageOverride");
+ ok(true, "homepageOverride.onChange fired");
+
+ if (
+ AppConstants.platform !== "android" &&
+ AppConstants.MOZ_APP_NAME !== "thunderbird"
+ ) {
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ assertPersistentListeners(
+ extension,
+ "browserSettings",
+ "newTabPageOverride",
+ {
+ primed: true,
+ }
+ );
+
+ info(`testing newTabPageOverride`);
+ AboutNewTab.newTabURL = "http://homepage.example.com";
+ await extension.awaitMessage("newTabPageOverride");
+ ok(true, "newTabPageOverride.onChange fired");
+ }
+
+ await extension.terminateBackground({ disableResetIdleForTest: true });
+ assertPersistentListeners(
+ extension,
+ "privacy",
+ "services.passwordSavingEnabled",
+ {
+ primed: true,
+ }
+ );
+
+ info(`testing passwordSavingEnabled`);
+ setExt.sendMessage("set", "privacy.services.passwordSavingEnabled", true);
+ await extension.awaitMessage("passwordSavingEnabled");
+ ok(true, "passwordSavingEnabled.onChange fired");
+
+ await AddonTestUtils.promiseRestartManager();
+ await setExt.awaitStartup();
+ await extension.awaitStartup();
+ Services.obs.notifyObservers(null, "browser-delayed-startup-finished");
+
+ assertPersistentListeners(extension, "browserSettings", "homepageOverride", {
+ primed: true,
+ });
+
+ info(`testing homepageOverride after AOM restart`);
+ Preferences.set("browser.startup.homepage", "http://test.example.com");
+ await extension.awaitMessage("homepageOverride");
+ ok(true, "homepageOverride.onChange fired");
+
+ await extension.unload();
+ await setExt.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js
new file mode 100644
index 0000000000..82c18e7015
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js
@@ -0,0 +1,99 @@
+"use strict";
+
+AddonTestUtils.init(this);
+// This test expects and checks deprecation warnings.
+ExtensionTestUtils.failOnSchemaWarnings(false);
+
+function createEventPageExtension(eventPage) {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ background: eventPage,
+ },
+ files: {
+ "event_page_script.js"() {
+ browser.test.log("running event page as background script");
+ browser.test.sendMessage("running", 1);
+ },
+ "event-page.html": `<!DOCTYPE html>
+ <html><head>
+ <meta charset="utf-8">
+ <script src="event_page_script.js"><\/script>
+ </head></html>`,
+ },
+ });
+}
+
+add_task(
+ {
+ // This test case covers expected warnings emitted when the
+ // event page support is disabled by prefs.
+ pref_set: [["extensions.eventPages.enabled", false]],
+ },
+ async function test_eventpages() {
+ let testCases = [
+ {
+ message: "testing event page running as a background page",
+ eventPage: {
+ page: "event-page.html",
+ persistent: false,
+ },
+ },
+ {
+ message: "testing event page scripts running as a background page",
+ eventPage: {
+ scripts: ["event_page_script.js"],
+ persistent: false,
+ },
+ },
+ {
+ message:
+ "testing additional unrecognized properties on background page",
+ eventPage: {
+ scripts: ["event_page_script.js"],
+ nonExistentProp: true,
+ },
+ },
+ {
+ message: "testing persistent background page",
+ eventPage: {
+ page: "event-page.html",
+ persistent: true,
+ },
+ },
+ {
+ message:
+ "testing scripts with persistent background running as a background page",
+ eventPage: {
+ scripts: ["event_page_script.js"],
+ persistent: true,
+ },
+ },
+ ];
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ for (let test of testCases) {
+ info(test.message);
+
+ let extension = createEventPageExtension(test.eventPage);
+ await extension.startup();
+ let x = await extension.awaitMessage("running");
+ equal(x, 1, "got correct value from extension");
+ await extension.unload();
+ }
+ });
+ AddonTestUtils.checkMessages(
+ messages,
+ {
+ expected: [
+ { message: /Event pages are not currently supported./ },
+ { message: /Event pages are not currently supported./ },
+ {
+ message:
+ /Reading manifest: Warning processing background.nonExistentProp: An unexpected property was found/,
+ },
+ ],
+ },
+ true
+ );
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js
new file mode 100644
index 0000000000..802ec69240
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js
@@ -0,0 +1,377 @@
+"use strict";
+
+/* globals browser */
+const { AddonSettings } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonSettings.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_task(async function setup() {
+ AddonTestUtils.overrideCertDB();
+ await ExtensionTestUtils.startAddonManager();
+});
+
+let fooExperimentAPIs = {
+ foo: {
+ schema: "schema.json",
+ parent: {
+ scopes: ["addon_parent"],
+ script: "parent.js",
+ paths: [["experiments", "foo", "parent"]],
+ },
+ child: {
+ scopes: ["addon_child"],
+ script: "child.js",
+ paths: [["experiments", "foo", "child"]],
+ },
+ },
+};
+
+let fooExperimentFiles = {
+ "schema.json": JSON.stringify([
+ {
+ namespace: "experiments.foo",
+ types: [
+ {
+ id: "Meh",
+ type: "object",
+ properties: {},
+ },
+ ],
+ functions: [
+ {
+ name: "parent",
+ type: "function",
+ async: true,
+ parameters: [],
+ },
+ {
+ name: "child",
+ type: "function",
+ parameters: [],
+ returns: { type: "string" },
+ },
+ ],
+ },
+ ]),
+
+ /* globals ExtensionAPI */
+ "parent.js": () => {
+ this.foo = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ experiments: {
+ foo: {
+ parent() {
+ return Promise.resolve("parent");
+ },
+ },
+ },
+ };
+ }
+ };
+ },
+
+ "child.js": () => {
+ this.foo = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ experiments: {
+ foo: {
+ child() {
+ return "child";
+ },
+ },
+ },
+ };
+ }
+ };
+ },
+};
+
+async function testFooExperiment() {
+ browser.test.assertEq(
+ "object",
+ typeof browser.experiments,
+ "typeof browser.experiments"
+ );
+
+ browser.test.assertEq(
+ "object",
+ typeof browser.experiments.foo,
+ "typeof browser.experiments.foo"
+ );
+
+ browser.test.assertEq(
+ "function",
+ typeof browser.experiments.foo.child,
+ "typeof browser.experiments.foo.child"
+ );
+
+ browser.test.assertEq(
+ "function",
+ typeof browser.experiments.foo.parent,
+ "typeof browser.experiments.foo.parent"
+ );
+
+ browser.test.assertEq(
+ "child",
+ browser.experiments.foo.child(),
+ "foo.child()"
+ );
+
+ browser.test.assertEq(
+ "parent",
+ await browser.experiments.foo.parent(),
+ "await foo.parent()"
+ );
+}
+
+async function testFooFailExperiment() {
+ browser.test.assertEq(
+ "object",
+ typeof browser.experiments,
+ "typeof browser.experiments"
+ );
+
+ browser.test.assertEq(
+ "undefined",
+ typeof browser.experiments.foo,
+ "typeof browser.experiments.foo"
+ );
+}
+
+add_task(async function test_bundled_experiments() {
+ let testCases = [
+ { isSystem: true, temporarilyInstalled: true, shouldHaveExperiments: true },
+ {
+ isSystem: true,
+ temporarilyInstalled: false,
+ shouldHaveExperiments: true,
+ },
+ {
+ isPrivileged: true,
+ temporarilyInstalled: true,
+ shouldHaveExperiments: true,
+ },
+ {
+ isPrivileged: true,
+ temporarilyInstalled: false,
+ shouldHaveExperiments: true,
+ },
+ {
+ isPrivileged: false,
+ temporarilyInstalled: true,
+ shouldHaveExperiments: AddonSettings.EXPERIMENTS_ENABLED,
+ },
+ {
+ isPrivileged: false,
+ temporarilyInstalled: false,
+ shouldHaveExperiments: AppConstants.MOZ_APP_NAME == "thunderbird",
+ },
+ ];
+
+ async function background(shouldHaveExperiments) {
+ if (shouldHaveExperiments) {
+ await testFooExperiment();
+ } else {
+ await testFooFailExperiment();
+ }
+
+ browser.test.notifyPass("background.experiments.foo");
+ }
+
+ for (let testCase of testCases) {
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged: testCase.isPrivileged,
+ isSystem: testCase.isSystem,
+ temporarilyInstalled: testCase.temporarilyInstalled,
+
+ manifest: {
+ experiment_apis: fooExperimentAPIs,
+ },
+
+ background: `
+ ${testFooExperiment}
+ ${testFooFailExperiment}
+ (${background})(${testCase.shouldHaveExperiments});
+ `,
+
+ files: fooExperimentFiles,
+ });
+
+ if (testCase.temporarilyInstalled && !testCase.shouldHaveExperiments) {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await Assert.rejects(
+ extension.startup(),
+ /Using 'experiment_apis' requires a privileged add-on/,
+ "startup failed without experimental api access"
+ );
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ } else {
+ await extension.startup();
+
+ await extension.awaitFinish("background.experiments.foo");
+
+ await extension.unload();
+ }
+ }
+});
+
+add_task(async function test_unbundled_experiments() {
+ async function background() {
+ await testFooExperiment();
+
+ browser.test.assertEq(
+ "object",
+ typeof browser.experiments.crunk,
+ "typeof browser.experiments.crunk"
+ );
+
+ browser.test.assertEq(
+ "function",
+ typeof browser.experiments.crunk.child,
+ "typeof browser.experiments.crunk.child"
+ );
+
+ browser.test.assertEq(
+ "function",
+ typeof browser.experiments.crunk.parent,
+ "typeof browser.experiments.crunk.parent"
+ );
+
+ browser.test.assertEq(
+ "crunk-child",
+ browser.experiments.crunk.child(),
+ "crunk.child()"
+ );
+
+ browser.test.assertEq(
+ "crunk-parent",
+ await browser.experiments.crunk.parent(),
+ "await crunk.parent()"
+ );
+
+ browser.test.notifyPass("background.experiments.crunk");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+
+ manifest: {
+ experiment_apis: fooExperimentAPIs,
+
+ permissions: ["experiments.crunk"],
+ },
+
+ background: `
+ ${testFooExperiment}
+ (${background})();
+ `,
+
+ files: fooExperimentFiles,
+ });
+
+ let apiExtension = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "crunk@experiments.addons.mozilla.org" },
+ },
+
+ experiment_apis: {
+ crunk: {
+ schema: "schema.json",
+ parent: {
+ scopes: ["addon_parent"],
+ script: "parent.js",
+ paths: [["experiments", "crunk", "parent"]],
+ },
+ child: {
+ scopes: ["addon_child"],
+ script: "child.js",
+ paths: [["experiments", "crunk", "child"]],
+ },
+ },
+ },
+ },
+
+ files: {
+ "schema.json": JSON.stringify([
+ {
+ namespace: "experiments.crunk",
+ types: [
+ {
+ id: "Meh",
+ type: "object",
+ properties: {},
+ },
+ ],
+ functions: [
+ {
+ name: "parent",
+ type: "function",
+ async: true,
+ parameters: [],
+ },
+ {
+ name: "child",
+ type: "function",
+ parameters: [],
+ returns: { type: "string" },
+ },
+ ],
+ },
+ ]),
+
+ "parent.js": () => {
+ this.crunk = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ experiments: {
+ crunk: {
+ parent() {
+ return Promise.resolve("crunk-parent");
+ },
+ },
+ },
+ };
+ }
+ };
+ },
+
+ "child.js": () => {
+ this.crunk = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ experiments: {
+ crunk: {
+ child() {
+ return "crunk-child";
+ },
+ },
+ },
+ };
+ }
+ };
+ },
+ },
+ });
+
+ await apiExtension.startup();
+ await extension.startup();
+
+ await extension.awaitFinish("background.experiments.crunk");
+
+ await extension.unload();
+ await apiExtension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension.js
new file mode 100644
index 0000000000..b50d8cd734
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension.js
@@ -0,0 +1,74 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_is_allowed_incognito_access() {
+ async function background() {
+ let allowed = await browser.extension.isAllowedIncognitoAccess();
+
+ browser.test.assertEq(true, allowed, "isAllowedIncognitoAccess is true");
+ browser.test.notifyPass("isAllowedIncognitoAccess");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ incognitoOverride: "spanning",
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("isAllowedIncognitoAccess");
+ await extension.unload();
+});
+
+add_task(async function test_is_denied_incognito_access() {
+ async function background() {
+ let allowed = await browser.extension.isAllowedIncognitoAccess();
+
+ browser.test.assertEq(false, allowed, "isAllowedIncognitoAccess is false");
+ browser.test.notifyPass("isNotAllowedIncognitoAccess");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("isNotAllowedIncognitoAccess");
+ await extension.unload();
+});
+
+add_task(async function test_in_incognito_context_false() {
+ function background() {
+ browser.test.assertEq(
+ false,
+ browser.extension.inIncognitoContext,
+ "inIncognitoContext returned false"
+ );
+ browser.test.notifyPass("inIncognitoContext");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("inIncognitoContext");
+ await extension.unload();
+});
+
+add_task(async function test_is_allowed_file_scheme_access() {
+ async function background() {
+ let allowed = await browser.extension.isAllowedFileSchemeAccess();
+
+ browser.test.assertEq(false, allowed, "isAllowedFileSchemeAccess is false");
+ browser.test.notifyPass("isAllowedFileSchemeAccess");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("isAllowedFileSchemeAccess");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js
new file mode 100644
index 0000000000..2f6e3dbe14
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js
@@ -0,0 +1,877 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionPreferencesManager:
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs",
+ ExtensionSettingsStore:
+ "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+
+var { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+
+const { createAppInfo, promiseShutdownManager, promiseStartupManager } =
+ AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+let lastSetPref;
+
+const STORE_TYPE = "prefs";
+
+// Test settings to use with the preferences manager.
+const SETTINGS = {
+ multiple_prefs: {
+ prefNames: ["my.pref.1", "my.pref.2", "my.pref.3"],
+
+ initalValues: ["value1", "value2", "value3"],
+
+ valueFn(pref, value) {
+ return `${pref}-${value}`;
+ },
+
+ setCallback(value) {
+ let prefs = {};
+ for (let pref of this.prefNames) {
+ prefs[pref] = this.valueFn(pref, value);
+ }
+ return prefs;
+ },
+ },
+
+ singlePref: {
+ prefNames: ["my.single.pref"],
+
+ initalValues: ["value1"],
+
+ onPrefsChanged(item) {
+ lastSetPref = item;
+ },
+
+ valueFn(pref, value) {
+ return value;
+ },
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: this.valueFn(null, value) };
+ },
+ },
+};
+
+ExtensionPreferencesManager.addSetting(
+ "multiple_prefs",
+ SETTINGS.multiple_prefs
+);
+ExtensionPreferencesManager.addSetting("singlePref", SETTINGS.singlePref);
+
+// Set initial values for prefs.
+for (let setting in SETTINGS) {
+ setting = SETTINGS[setting];
+ for (let i = 0; i < setting.prefNames.length; i++) {
+ Preferences.set(setting.prefNames[i], setting.initalValues[i]);
+ }
+}
+
+function checkPrefs(settingObj, value, msg) {
+ for (let pref of settingObj.prefNames) {
+ equal(Preferences.get(pref), settingObj.valueFn(pref, value), msg);
+ }
+}
+
+function checkOnPrefsChanged(setting, value, msg) {
+ if (value) {
+ deepEqual(lastSetPref, value, msg);
+ lastSetPref = null;
+ } else {
+ ok(!lastSetPref, msg);
+ }
+}
+
+add_task(async function test_preference_manager() {
+ await promiseStartupManager();
+
+ // Create an array of test framework extension wrappers to install.
+ let testExtensions = [
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {},
+ }),
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {},
+ }),
+ ];
+
+ for (let extension of testExtensions) {
+ await extension.startup();
+ }
+
+ // Create an array actual Extension objects which correspond to the
+ // test framework extension wrappers.
+ let extensions = testExtensions.map(extension => extension.extension);
+
+ for (let setting in SETTINGS) {
+ let settingObj = SETTINGS[setting];
+ let newValue1 = "newValue1";
+ let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ extensions[1].id,
+ setting
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ null,
+ "onPrefsChanged has not been called yet"
+ );
+ }
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl with no settings set."
+ );
+
+ let prefsChanged = await ExtensionPreferencesManager.setSetting(
+ extensions[1].id,
+ setting,
+ newValue1
+ );
+ ok(prefsChanged, "setSetting returns true when the pref(s) have been set.");
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "setSetting sets the prefs for the first extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ { id: extensions[1].id, value: newValue1, key: setting },
+ "onPrefsChanged is called when pref changes"
+ );
+ }
+ levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ extensions[1].id,
+ setting
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl when a pref has been set."
+ );
+
+ let checkSetting = await ExtensionPreferencesManager.getSetting(setting);
+ equal(
+ checkSetting.value,
+ newValue1,
+ "getSetting returns the expected value."
+ );
+
+ let newValue2 = "newValue2";
+ prefsChanged = await ExtensionPreferencesManager.setSetting(
+ extensions[0].id,
+ setting,
+ newValue2
+ );
+ ok(
+ !prefsChanged,
+ "setSetting returns false when the pref(s) have not been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "setSetting does not set the pref(s) for an earlier extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ null,
+ "onPrefsChanged isn't called without control change"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.disableSetting(
+ extensions[0].id,
+ setting
+ );
+ ok(
+ !prefsChanged,
+ "disableSetting returns false when the pref(s) have not been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "disableSetting does not change the pref(s) for the non-top extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ null,
+ "onPrefsChanged isn't called without control change on disable"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.enableSetting(
+ extensions[0].id,
+ setting
+ );
+ ok(
+ !prefsChanged,
+ "enableSetting returns false when the pref(s) have not been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "enableSetting does not change the pref(s) for the non-top extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ null,
+ "onPrefsChanged isn't called without control change on enable"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.removeSetting(
+ extensions[0].id,
+ setting
+ );
+ ok(
+ !prefsChanged,
+ "removeSetting returns false when the pref(s) have not been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "removeSetting does not change the pref(s) for the non-top extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ null,
+ "onPrefsChanged isn't called without control change on remove"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.setSetting(
+ extensions[0].id,
+ setting,
+ newValue2
+ );
+ ok(
+ !prefsChanged,
+ "setSetting returns false when the pref(s) have not been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "setSetting does not set the pref(s) for an earlier extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ null,
+ "onPrefsChanged isn't called without control change again"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.disableSetting(
+ extensions[1].id,
+ setting
+ );
+ ok(
+ prefsChanged,
+ "disableSetting returns true when the pref(s) have been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue2,
+ "disableSetting sets the pref(s) to the next value when disabling the top extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ { id: extensions[0].id, key: setting, value: newValue2 },
+ "onPrefsChanged is called when control changes on disable"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.enableSetting(
+ extensions[1].id,
+ setting
+ );
+ ok(
+ prefsChanged,
+ "enableSetting returns true when the pref(s) have been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "enableSetting sets the pref(s) to the previous value(s)."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ { id: extensions[1].id, key: setting, value: newValue1 },
+ "onPrefsChanged is called when control changes on enable"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.removeSetting(
+ extensions[1].id,
+ setting
+ );
+ ok(
+ prefsChanged,
+ "removeSetting returns true when the pref(s) have been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue2,
+ "removeSetting sets the pref(s) to the next value when removing the top extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ { id: extensions[0].id, key: setting, value: newValue2 },
+ "onPrefsChanged is called when control changes on remove"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.removeSetting(
+ extensions[0].id,
+ setting
+ );
+ ok(
+ prefsChanged,
+ "removeSetting returns true when the pref(s) have been set."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ { key: setting, initialValue: { "my.single.pref": "value1" } },
+ "onPrefsChanged is called when control is entirely removed"
+ );
+ }
+ for (let i = 0; i < settingObj.prefNames.length; i++) {
+ equal(
+ Preferences.get(settingObj.prefNames[i]),
+ settingObj.initalValues[i],
+ "removeSetting sets the pref(s) to the initial value(s) when removing the last extension."
+ );
+ }
+
+ checkSetting = await ExtensionPreferencesManager.getSetting(setting);
+ equal(
+ checkSetting,
+ null,
+ "getSetting returns null when nothing has been set."
+ );
+ }
+
+ // Tests for unsetAll.
+ let newValue3 = "newValue3";
+ for (let setting in SETTINGS) {
+ let settingObj = SETTINGS[setting];
+ await ExtensionPreferencesManager.setSetting(
+ extensions[0].id,
+ setting,
+ newValue3
+ );
+ checkPrefs(settingObj, newValue3, "setSetting set the pref.");
+ }
+
+ let setSettings = await ExtensionSettingsStore.getAllForExtension(
+ extensions[0].id,
+ STORE_TYPE
+ );
+ deepEqual(
+ setSettings,
+ Object.keys(SETTINGS),
+ "Expected settings were set for extension."
+ );
+ await ExtensionPreferencesManager.disableAll(extensions[0].id);
+
+ for (let setting in SETTINGS) {
+ let settingObj = SETTINGS[setting];
+ for (let i = 0; i < settingObj.prefNames.length; i++) {
+ equal(
+ Preferences.get(settingObj.prefNames[i]),
+ settingObj.initalValues[i],
+ "disableAll unset the pref."
+ );
+ }
+ }
+
+ setSettings = await ExtensionSettingsStore.getAllForExtension(
+ extensions[0].id,
+ STORE_TYPE
+ );
+ deepEqual(
+ setSettings,
+ Object.keys(SETTINGS),
+ "disableAll retains the settings."
+ );
+
+ await ExtensionPreferencesManager.enableAll(extensions[0].id);
+ for (let setting in SETTINGS) {
+ let settingObj = SETTINGS[setting];
+ checkPrefs(settingObj, newValue3, "enableAll re-set the pref.");
+ }
+
+ await ExtensionPreferencesManager.removeAll(extensions[0].id);
+
+ for (let setting in SETTINGS) {
+ let settingObj = SETTINGS[setting];
+ for (let i = 0; i < settingObj.prefNames.length; i++) {
+ equal(
+ Preferences.get(settingObj.prefNames[i]),
+ settingObj.initalValues[i],
+ "removeAll unset the pref."
+ );
+ }
+ }
+
+ setSettings = await ExtensionSettingsStore.getAllForExtension(
+ extensions[0].id,
+ STORE_TYPE
+ );
+ deepEqual(setSettings, [], "removeAll removed all settings.");
+
+ // Tests for preventing automatic changes to manually edited prefs.
+ for (let setting in SETTINGS) {
+ let apiValue = "newValue";
+ let manualValue = "something different";
+ let settingObj = SETTINGS[setting];
+ let extension = extensions[1];
+ await ExtensionPreferencesManager.setSetting(
+ extension.id,
+ setting,
+ apiValue
+ );
+
+ let checkResetPrefs = method => {
+ let prefNames = settingObj.prefNames;
+ for (let i = 0; i < prefNames.length; i++) {
+ if (i === 0) {
+ equal(
+ Preferences.get(prefNames[0]),
+ manualValue,
+ `${method} did not change a manually set pref.`
+ );
+ } else {
+ equal(
+ Preferences.get(prefNames[i]),
+ settingObj.valueFn(prefNames[i], apiValue),
+ `${method} did not change another pref when a pref was manually set.`
+ );
+ }
+ }
+ };
+
+ // Manually set the preference to a different value.
+ Preferences.set(settingObj.prefNames[0], manualValue);
+
+ await ExtensionPreferencesManager.disableAll(extension.id);
+ checkResetPrefs("disableAll");
+
+ await ExtensionPreferencesManager.enableAll(extension.id);
+ checkResetPrefs("enableAll");
+
+ await ExtensionPreferencesManager.removeAll(extension.id);
+ checkResetPrefs("removeAll");
+ }
+
+ // Test with an uninitialized pref.
+ let setting = "singlePref";
+ let settingObj = SETTINGS[setting];
+ let pref = settingObj.prefNames[0];
+ let newValue = "newValue";
+ Preferences.reset(pref);
+ await ExtensionPreferencesManager.setSetting(
+ extensions[1].id,
+ setting,
+ newValue
+ );
+ equal(
+ Preferences.get(pref),
+ settingObj.valueFn(pref, newValue),
+ "Uninitialized pref is set."
+ );
+ await ExtensionPreferencesManager.removeSetting(extensions[1].id, setting);
+ ok(!Preferences.has(pref), "removeSetting removed the pref.");
+
+ // Test levelOfControl with a locked pref.
+ setting = "multiple_prefs";
+ let prefToLock = SETTINGS[setting].prefNames[0];
+ Preferences.lock(prefToLock, 1);
+ ok(Preferences.locked(prefToLock), `Preference ${prefToLock} is locked.`);
+ let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ extensions[1].id,
+ setting
+ );
+ equal(
+ levelOfControl,
+ "not_controllable",
+ "getLevelOfControl returns correct levelOfControl when a pref is locked."
+ );
+
+ for (let extension of testExtensions) {
+ await extension.unload();
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_preference_manager_set_when_disabled() {
+ await promiseStartupManager();
+
+ let id = "@set-disabled-pref";
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ await extension.startup();
+
+ // We test both a default pref and a user-set pref. Get the default
+ // value off the pref we'll use. We fake the default pref by setting
+ // a value on it before creating the setting.
+ Services.prefs.setBoolPref("bar", true);
+
+ function isUndefinedPref(pref) {
+ try {
+ Services.prefs.getStringPref(pref);
+ return false;
+ } catch (e) {
+ return true;
+ }
+ }
+ ok(isUndefinedPref("foo"), "test pref is not set");
+
+ await ExtensionSettingsStore.initialize();
+ let lastItemChange = PromiseUtils.defer();
+ ExtensionPreferencesManager.addSetting("some-pref", {
+ prefNames: ["foo", "bar"],
+ onPrefsChanged(item) {
+ lastItemChange.resolve(item);
+ lastItemChange = PromiseUtils.defer();
+ },
+ setCallback(value) {
+ return { [this.prefNames[0]]: value, [this.prefNames[1]]: false };
+ },
+ });
+
+ await ExtensionPreferencesManager.setSetting(id, "some-pref", "my value");
+
+ let item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, "my value", "The value has been set");
+ equal(
+ Services.prefs.getStringPref("foo"),
+ "my value",
+ "The user pref has been set"
+ );
+ equal(
+ Services.prefs.getBoolPref("bar"),
+ false,
+ "The default pref has been set"
+ );
+
+ await ExtensionPreferencesManager.disableSetting(id, "some-pref");
+
+ // test that a disabled setting has been returned to the default value. In this
+ // case the pref is not a default pref, so it will be undefined.
+ item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, undefined, "The value is back to default");
+ equal(item.initialValue.foo, undefined, "The initialValue is correct");
+ ok(isUndefinedPref("foo"), "user pref is not set");
+ equal(
+ Services.prefs.getBoolPref("bar"),
+ true,
+ "The default pref has been restored to the default"
+ );
+
+ // test that setSetting() will enable a disabled setting
+ await ExtensionPreferencesManager.setSetting(id, "some-pref", "new value");
+
+ item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, "new value", "The value is set again");
+ equal(
+ Services.prefs.getStringPref("foo"),
+ "new value",
+ "The user pref is set again"
+ );
+ equal(
+ Services.prefs.getBoolPref("bar"),
+ false,
+ "The default pref has been set again"
+ );
+
+ // Force settings to be serialized and reloaded to mimick what happens
+ // with settings through a restart of Firefox. Bug 1576266.
+ await ExtensionSettingsStore._reloadFile(true);
+
+ // Now unload the extension to test prefs are reset properly.
+ let promise = lastItemChange.promise;
+ await extension.unload();
+
+ // Test that the pref is unset when an extension is uninstalled.
+ item = await promise;
+ deepEqual(
+ item,
+ { key: "some-pref", initialValue: { bar: true } },
+ "The value has been reset"
+ );
+ ok(isUndefinedPref("foo"), "user pref is not set");
+ equal(
+ Services.prefs.getBoolPref("bar"),
+ true,
+ "The default pref has been restored to the default"
+ );
+ Services.prefs.clearUserPref("bar");
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_preference_default_upgraded() {
+ await promiseStartupManager();
+
+ let id = "@upgrade-pref";
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ await extension.startup();
+
+ // We set the default value for a pref here so it will be
+ // picked up by EPM.
+ let defaultPrefs = Services.prefs.getDefaultBranch(null);
+ defaultPrefs.setStringPref("bar", "initial default");
+
+ await ExtensionSettingsStore.initialize();
+ ExtensionPreferencesManager.addSetting("some-pref", {
+ prefNames: ["bar"],
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+ });
+
+ await ExtensionPreferencesManager.setSetting(id, "some-pref", "new value");
+ let item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, "new value", "The value is set");
+
+ defaultPrefs.setStringPref("bar", "new default");
+
+ item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, "new value", "The value is still set");
+
+ let prefsChanged = await ExtensionPreferencesManager.removeSetting(
+ id,
+ "some-pref"
+ );
+ ok(prefsChanged, "pref changed on removal of setting.");
+ equal(Preferences.get("bar"), "new default", "default value is correct");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+add_task(async function test_preference_select() {
+ await promiseStartupManager();
+
+ let extensionData = {
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@one" } },
+ },
+ };
+ let one = ExtensionTestUtils.loadExtension(extensionData);
+
+ await one.startup();
+
+ // We set the default value for a pref here so it will be
+ // picked up by EPM.
+ let defaultPrefs = Services.prefs.getDefaultBranch(null);
+ defaultPrefs.setStringPref("bar", "initial default");
+
+ await ExtensionSettingsStore.initialize();
+ ExtensionPreferencesManager.addSetting("some-pref", {
+ prefNames: ["bar"],
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+ });
+
+ ok(
+ await ExtensionPreferencesManager.setSetting(
+ one.id,
+ "some-pref",
+ "new value"
+ ),
+ "setting was changed"
+ );
+ let item = await ExtensionPreferencesManager.getSetting("some-pref");
+ equal(item.value, "new value", "The value is set");
+
+ // User-set the setting.
+ await ExtensionPreferencesManager.selectSetting(null, "some-pref");
+ item = await ExtensionPreferencesManager.getSetting("some-pref");
+ deepEqual(
+ item,
+ { key: "some-pref", initialValue: {} },
+ "The value is user-set"
+ );
+
+ // Extensions installed before cannot gain control again.
+ let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ one.id,
+ "some-pref"
+ );
+ equal(
+ levelOfControl,
+ "not_controllable",
+ "getLevelOfControl returns correct levelOfControl when user-set."
+ );
+
+ // Enabling the top-precedence addon does not take over a user-set setting.
+ await ExtensionPreferencesManager.disableSetting(one.id, "some-pref");
+ await ExtensionPreferencesManager.enableSetting(one.id, "some-pref");
+ item = await ExtensionPreferencesManager.getSetting("some-pref");
+ deepEqual(
+ item,
+ { key: "some-pref", initialValue: {} },
+ "The value is user-set"
+ );
+
+ // Upgrading does not override the user-set setting.
+ extensionData.manifest.version = "2.0";
+ extensionData.manifest.incognito = "not_allowed";
+ await one.upgrade(extensionData);
+ levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ one.id,
+ "some-pref"
+ );
+ equal(
+ levelOfControl,
+ "not_controllable",
+ "getLevelOfControl returns correct levelOfControl when user-set after addon upgrade."
+ );
+
+ // We can re-select the extension.
+ await ExtensionPreferencesManager.selectSetting(one.id, "some-pref");
+ item = await ExtensionPreferencesManager.getSetting("some-pref");
+ deepEqual(item.value, "new value", "The value is extension set");
+
+ // An extension installed after user-set can take over the setting.
+ await ExtensionPreferencesManager.selectSetting(null, "some-pref");
+ item = await ExtensionPreferencesManager.getSetting("some-pref");
+ deepEqual(
+ item,
+ { key: "some-pref", initialValue: {} },
+ "The value is user-set"
+ );
+
+ let two = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@two" } },
+ },
+ });
+
+ await two.startup();
+ levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ two.id,
+ "some-pref"
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl when user-set after addon install."
+ );
+
+ await ExtensionPreferencesManager.setSetting(
+ two.id,
+ "some-pref",
+ "another value"
+ );
+ item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, "another value", "The value is set");
+
+ // A new installed extension can override a user selected extension.
+ let three = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@three" } },
+ },
+ });
+
+ // user selects specific extension to take control
+ await ExtensionPreferencesManager.selectSetting(one.id, "some-pref");
+
+ // two cannot control
+ levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ two.id,
+ "some-pref"
+ );
+ equal(
+ levelOfControl,
+ "not_controllable",
+ "getLevelOfControl returns correct levelOfControl when user-set after addon install."
+ );
+
+ // three can control after install
+ await three.startup();
+ levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ three.id,
+ "some-pref"
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl when user-set after addon install."
+ );
+
+ await ExtensionPreferencesManager.setSetting(
+ three.id,
+ "some-pref",
+ "third value"
+ );
+ item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, "third value", "The value is set");
+
+ // We have returned to precedence based settings.
+ await ExtensionPreferencesManager.removeSetting(three.id, "some-pref");
+ await ExtensionPreferencesManager.removeSetting(two.id, "some-pref");
+ item = await ExtensionPreferencesManager.getSetting("some-pref");
+ equal(item.value, "new value", "The value is extension set");
+
+ await one.unload();
+ await two.unload();
+ await three.unload();
+ await promiseShutdownManager();
+});
+
+add_task(async function test_preference_select() {
+ let prefNames = await ExtensionPreferencesManager.getManagedPrefDetails();
+ // Just check a subset of settings that are in this test file.
+ Assert.ok(prefNames.size > 0, "some prefs exist");
+ for (let settingName in SETTINGS) {
+ let setting = SETTINGS[settingName];
+ for (let prefName of setting.prefNames) {
+ Assert.equal(
+ prefNames.get(prefName),
+ settingName,
+ "setting retrieved prefNames"
+ );
+ }
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js
new file mode 100644
index 0000000000..720c7f539a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js
@@ -0,0 +1,1085 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionSettingsStore:
+ "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
+});
+
+const { createAppInfo, promiseShutdownManager, promiseStartupManager } =
+ AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+// Allow for unsigned addons.
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+const ITEMS = {
+ key1: [
+ { key: "key1", value: "val1", id: "@first" },
+ { key: "key1", value: "val2", id: "@second" },
+ { key: "key1", value: "val3", id: "@third" },
+ ],
+ key2: [
+ { key: "key2", value: "val1-2", id: "@first" },
+ { key: "key2", value: "val2-2", id: "@second" },
+ { key: "key2", value: "val3-2", id: "@third" },
+ ],
+};
+const KEY_LIST = Object.keys(ITEMS);
+const TEST_TYPE = "myType";
+
+let callbackCount = 0;
+
+function initialValue(key) {
+ callbackCount++;
+ return `key:${key}`;
+}
+
+add_task(async function test_settings_store() {
+ await promiseStartupManager();
+
+ // Create an array of test framework extension wrappers to install.
+ let testExtensions = [
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@first" } },
+ },
+ }),
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@second" } },
+ },
+ }),
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@third" } },
+ },
+ }),
+ ];
+
+ for (let extension of testExtensions) {
+ await extension.startup();
+ }
+
+ // Create an array actual Extension objects which correspond to the
+ // test framework extension wrappers.
+ let extensions = testExtensions.map(extension => extension.extension);
+
+ let expectedCallbackCount = 0;
+
+ await Assert.rejects(
+ ExtensionSettingsStore.getLevelOfControl(1, TEST_TYPE, "key"),
+ /The ExtensionSettingsStore was accessed before the initialize promise resolved/,
+ "Accessing the SettingsStore before it is initialized throws an error."
+ );
+
+ // Initialize the SettingsStore.
+ await ExtensionSettingsStore.initialize();
+
+ // Add a setting for the second oldest extension, where it is the only setting for a key.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 1;
+ let itemToAdd = ITEMS[key][extensionIndex];
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl with no settings set for a key."
+ );
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ itemToAdd.key,
+ itemToAdd.value,
+ initialValue
+ );
+ expectedCallbackCount++;
+ equal(
+ callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times."
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "Adding initial item for a key returns that item."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting returns correct item with only one item in the list."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl with only one item in the list."
+ );
+ ok(
+ ExtensionSettingsStore.hasSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ ),
+ "hasSetting returns the correct value when an extension has a setting set."
+ );
+ item = await ExtensionSettingsStore.getSetting(
+ TEST_TYPE,
+ key,
+ extensions[extensionIndex].id
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting with id returns correct item with only one item in the list."
+ );
+ }
+
+ // Add a setting for the oldest extension.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 0;
+ let itemToAdd = ITEMS[key][extensionIndex];
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ itemToAdd.key,
+ itemToAdd.value,
+ initialValue
+ );
+ equal(
+ callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times."
+ );
+ equal(
+ item,
+ null,
+ "An older extension adding a setting for a key returns null"
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][1],
+ "getSetting returns correct item with more than one item in the list."
+ );
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_other_extensions",
+ "getLevelOfControl returns correct levelOfControl when another extension is in control."
+ );
+ item = await ExtensionSettingsStore.getSetting(
+ TEST_TYPE,
+ key,
+ extensions[extensionIndex].id
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting with id returns correct item with more than one item in the list."
+ );
+ }
+
+ // Reload the settings store to emulate a browser restart.
+ await ExtensionSettingsStore._reloadFile();
+
+ // Add a setting for the newest extension.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 2;
+ let itemToAdd = ITEMS[key][extensionIndex];
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl for a more recent extension."
+ );
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ itemToAdd.key,
+ itemToAdd.value,
+ initialValue
+ );
+ equal(
+ callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times."
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "Adding item for most recent extension returns that item."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting returns correct item with more than one item in the list."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl when this extension is in control."
+ );
+ item = await ExtensionSettingsStore.getSetting(
+ TEST_TYPE,
+ key,
+ extensions[extensionIndex].id
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting with id returns correct item with more than one item in the list."
+ );
+ }
+
+ for (let extension of extensions) {
+ let items = await ExtensionSettingsStore.getAllForExtension(
+ extension.id,
+ TEST_TYPE
+ );
+ deepEqual(items, KEY_LIST, "getAllForExtension returns expected keys.");
+ }
+
+ // Attempting to remove a setting that has not been set should *not* throw an exception.
+ let removeResult = await ExtensionSettingsStore.removeSetting(
+ extensions[0].id,
+ "myType",
+ "unset_key"
+ );
+ equal(
+ removeResult,
+ null,
+ "Removing a setting that was not previously set returns null."
+ );
+
+ // Attempting to disable a setting that has not been set should throw an exception.
+ Assert.throws(
+ () =>
+ ExtensionSettingsStore.disable(extensions[0].id, "myType", "unset_key"),
+ /Cannot alter the setting for myType:unset_key as it does not exist/,
+ "disable rejects with an unset key."
+ );
+
+ // Attempting to enable a setting that has not been set should throw an exception.
+ Assert.throws(
+ () =>
+ ExtensionSettingsStore.enable(extensions[0].id, "myType", "unset_key"),
+ /Cannot alter the setting for myType:unset_key as it does not exist/,
+ "enable rejects with an unset key."
+ );
+
+ let expectedKeys = KEY_LIST;
+ // Disable the non-top item for a key.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 0;
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key,
+ "new value",
+ initialValue
+ );
+ equal(
+ callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times."
+ );
+ equal(item, null, "Updating non-top item for a key returns null");
+ item = await ExtensionSettingsStore.disable(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(item, null, "Disabling non-top item for a key returns null.");
+ let allForExtension = await ExtensionSettingsStore.getAllForExtension(
+ extensions[extensionIndex].id,
+ TEST_TYPE
+ );
+ deepEqual(
+ allForExtension,
+ expectedKeys,
+ "getAllForExtension returns expected keys after a disable."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][2],
+ "getSetting returns correct item after a disable."
+ );
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_other_extensions",
+ "getLevelOfControl returns correct levelOfControl after disabling of non-top item."
+ );
+ }
+
+ // Re-enable the non-top item for a key.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 0;
+ let item = await ExtensionSettingsStore.enable(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(item, null, "Enabling non-top item for a key returns null.");
+ let allForExtension = await ExtensionSettingsStore.getAllForExtension(
+ extensions[extensionIndex].id,
+ TEST_TYPE
+ );
+ deepEqual(
+ allForExtension,
+ expectedKeys,
+ "getAllForExtension returns expected keys after an enable."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][2],
+ "getSetting returns correct item after an enable."
+ );
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_other_extensions",
+ "getLevelOfControl returns correct levelOfControl after enabling of non-top item."
+ );
+ }
+
+ // Remove the non-top item for a key.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 0;
+ let item = await ExtensionSettingsStore.removeSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(item, null, "Removing non-top item for a key returns null.");
+ expectedKeys = expectedKeys.filter(expectedKey => expectedKey != key);
+ let allForExtension = await ExtensionSettingsStore.getAllForExtension(
+ extensions[extensionIndex].id,
+ TEST_TYPE
+ );
+ deepEqual(
+ allForExtension,
+ expectedKeys,
+ "getAllForExtension returns expected keys after a removal."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][2],
+ "getSetting returns correct item after a removal."
+ );
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_other_extensions",
+ "getLevelOfControl returns correct levelOfControl after removal of non-top item."
+ );
+ ok(
+ !ExtensionSettingsStore.hasSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ ),
+ "hasSetting returns the correct value when an extension does not have a setting set."
+ );
+ }
+
+ for (let key of KEY_LIST) {
+ // Disable the top item for a key.
+ let item = await ExtensionSettingsStore.disable(
+ extensions[2].id,
+ TEST_TYPE,
+ key
+ );
+ deepEqual(
+ item,
+ ITEMS[key][1],
+ "Disabling top item for a key returns the new top item."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][1],
+ "getSetting returns correct item after a disable."
+ );
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[2].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after disabling of top item."
+ );
+
+ // Re-enable the top item for a key.
+ item = await ExtensionSettingsStore.enable(
+ extensions[2].id,
+ TEST_TYPE,
+ key
+ );
+ deepEqual(
+ item,
+ ITEMS[key][2],
+ "Re-enabling top item for a key returns the old top item."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][2],
+ "getSetting returns correct item after an enable."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[2].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after re-enabling top item."
+ );
+
+ // Remove the top item for a key.
+ item = await ExtensionSettingsStore.removeSetting(
+ extensions[2].id,
+ TEST_TYPE,
+ key
+ );
+ deepEqual(
+ item,
+ ITEMS[key][1],
+ "Removing top item for a key returns the new top item."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][1],
+ "getSetting returns correct item after a removal."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[2].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after removal of top item."
+ );
+
+ // Add a setting for the current top item.
+ let itemToAdd = { key, value: `new-${key}`, id: "@second" };
+ item = await ExtensionSettingsStore.addSetting(
+ extensions[1].id,
+ TEST_TYPE,
+ itemToAdd.key,
+ itemToAdd.value,
+ initialValue
+ );
+ equal(
+ callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times."
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "Updating top item for a key returns that item."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting returns correct item after updating."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after updating."
+ );
+
+ // Disable the last remaining item for a key.
+ let expectedItem = { key, initialValue: initialValue(key) };
+ // We're using the callback to set the expected value, so we need to increment the
+ // expectedCallbackCount.
+ expectedCallbackCount++;
+ item = await ExtensionSettingsStore.disable(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ deepEqual(
+ item,
+ expectedItem,
+ "Disabling last item for a key returns the initial value."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ expectedItem,
+ "getSetting returns the initial value after all are disabled."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after all are disabled."
+ );
+
+ // Re-enable the last remaining item for a key.
+ item = await ExtensionSettingsStore.enable(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "Re-enabling last item for a key returns the old value."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting returns expected value after re-enabling."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after re-enabling."
+ );
+
+ // Remove the last remaining item for a key.
+ item = await ExtensionSettingsStore.removeSetting(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ deepEqual(
+ item,
+ expectedItem,
+ "Removing last item for a key returns the initial value."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(item, null, "getSetting returns null after all are removed.");
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after all are removed."
+ );
+
+ // Attempting to remove a setting that has had all extensions removed should *not* throw an exception.
+ removeResult = await ExtensionSettingsStore.removeSetting(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ removeResult,
+ null,
+ "Removing a setting that has had all extensions removed returns null."
+ );
+ }
+
+ // Test adding a setting with a value in callbackArgument.
+ let extensionIndex = 0;
+ let testKey = "callbackArgumentKey";
+ let callbackArgumentValue = Date.now();
+ // Add the setting.
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ testKey,
+ 1,
+ initialValue,
+ callbackArgumentValue
+ );
+ expectedCallbackCount++;
+ equal(
+ callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times."
+ );
+ // Remove the setting which should return the initial value.
+ let expectedItem = {
+ key: testKey,
+ initialValue: initialValue(callbackArgumentValue),
+ };
+ // We're using the callback to set the expected value, so we need to increment the
+ // expectedCallbackCount.
+ expectedCallbackCount++;
+ item = await ExtensionSettingsStore.removeSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ testKey
+ );
+ deepEqual(
+ item,
+ expectedItem,
+ "Removing last item for a key returns the initial value."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, testKey);
+ deepEqual(item, null, "getSetting returns null after all are removed.");
+
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, "not a key");
+ equal(
+ item,
+ null,
+ "getSetting returns a null item if the setting does not have any records."
+ );
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[1].id,
+ TEST_TYPE,
+ "not a key"
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl if the setting does not have any records."
+ );
+
+ for (let extension of testExtensions) {
+ await extension.unload();
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_settings_store_setByUser() {
+ await promiseStartupManager();
+
+ // Create an array of test framework extension wrappers to install.
+ let testExtensions = [
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@first" } },
+ },
+ }),
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@second" } },
+ },
+ }),
+ ];
+
+ let type = "some_type";
+ let key = "some_key";
+
+ for (let extension of testExtensions) {
+ await extension.startup();
+ }
+
+ // Create an array actual Extension objects which correspond to the
+ // test framework extension wrappers.
+ let [one, two] = testExtensions.map(extension => extension.extension);
+ let initialCallback = () => "initial";
+
+ // Initialize the SettingsStore.
+ await ExtensionSettingsStore.initialize();
+
+ equal(
+ null,
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting is initially null"
+ );
+
+ let item = await ExtensionSettingsStore.addSetting(
+ one.id,
+ type,
+ key,
+ "one",
+ initialCallback
+ );
+ deepEqual(
+ { key, value: "one", id: one.id },
+ item,
+ "addSetting returns the first set item"
+ );
+
+ item = await ExtensionSettingsStore.addSetting(
+ two.id,
+ type,
+ key,
+ "two",
+ initialCallback
+ );
+ deepEqual(
+ { key, value: "two", id: two.id },
+ item,
+ "addSetting returns the second set item"
+ );
+
+ // a user-set selection reverts to precedence order when new
+ // extension sets the setting.
+ ExtensionSettingsStore.select(
+ ExtensionSettingsStore.SETTING_USER_SET,
+ type,
+ key
+ );
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after being set by user"
+ );
+
+ let three = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@third" } },
+ },
+ });
+ await three.startup();
+
+ item = await ExtensionSettingsStore.addSetting(
+ three.id,
+ type,
+ key,
+ "three",
+ initialCallback
+ );
+ deepEqual(
+ { key, value: "three", id: three.id },
+ item,
+ "addSetting returns the third set item"
+ );
+ deepEqual(
+ item,
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the third set item"
+ );
+
+ ExtensionSettingsStore.select(
+ ExtensionSettingsStore.SETTING_USER_SET,
+ type,
+ key
+ );
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after being set by user"
+ );
+
+ item = ExtensionSettingsStore.select(one.id, type, key);
+ deepEqual(
+ { key, value: "one", id: one.id },
+ item,
+ "selecting an extension returns the first set item after enable"
+ );
+
+ // Disabling a selected item returns to precedence order
+ ExtensionSettingsStore.disable(one.id, type, key);
+ deepEqual(
+ { key, value: "three", id: three.id },
+ ExtensionSettingsStore.getSetting(type, key),
+ "returning to precedence order sets the third set item"
+ );
+
+ // Test that disabling all then enabling one does not take over a user-set setting.
+ ExtensionSettingsStore.select(
+ ExtensionSettingsStore.SETTING_USER_SET,
+ type,
+ key
+ );
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after being set by user"
+ );
+
+ ExtensionSettingsStore.disable(three.id, type, key);
+ ExtensionSettingsStore.disable(two.id, type, key);
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after disabling all extensions"
+ );
+
+ ExtensionSettingsStore.enable(three.id, type, key);
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after enabling one extension"
+ );
+
+ // Ensure that calling addSetting again will not reset a user-set value when
+ // the extension install date is older than the user-set date.
+ item = await ExtensionSettingsStore.addSetting(
+ three.id,
+ type,
+ key,
+ "three",
+ initialCallback
+ );
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after calling addSetting for old addon"
+ );
+
+ item = ExtensionSettingsStore.enable(three.id, type, key);
+ equal(undefined, item, "enabling the active item does not return an item");
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after enabling one extension"
+ );
+
+ ExtensionSettingsStore.removeSetting(three.id, type, key);
+ ExtensionSettingsStore.removeSetting(two.id, type, key);
+ ExtensionSettingsStore.removeSetting(one.id, type, key);
+
+ equal(
+ null,
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns null after removing all settings"
+ );
+
+ for (let extension of testExtensions) {
+ await extension.unload();
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_settings_store_add_disabled() {
+ await promiseStartupManager();
+
+ let id = "@add-on-disable";
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ await extension.startup();
+ await ExtensionSettingsStore.initialize();
+
+ await ExtensionSettingsStore.addSetting(
+ id,
+ "foo",
+ "bar",
+ "set",
+ () => "not set"
+ );
+
+ let item = ExtensionSettingsStore.getSetting("foo", "bar");
+ equal(item.id, id, "The add-on is in control");
+ equal(item.value, "set", "The value is set");
+
+ ExtensionSettingsStore.disable(id, "foo", "bar");
+ item = ExtensionSettingsStore.getSetting("foo", "bar");
+ equal(item.id, undefined, "The add-on is not in control");
+ equal(item.initialValue, "not set", "The value is not set");
+
+ await ExtensionSettingsStore.addSetting(
+ id,
+ "foo",
+ "bar",
+ "set",
+ () => "not set"
+ );
+ item = ExtensionSettingsStore.getSetting("foo", "bar");
+ equal(item.id, id, "The add-on is in control");
+ equal(item.value, "set", "The value is set");
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_settings_uninstall_remove() {
+ await promiseStartupManager();
+
+ let id = "@add-on-uninstall";
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ await extension.startup();
+ await ExtensionSettingsStore.initialize();
+
+ await ExtensionSettingsStore.addSetting(
+ id,
+ "foo",
+ "bar",
+ "set",
+ () => "not set"
+ );
+
+ let item = ExtensionSettingsStore.getSetting("foo", "bar");
+ equal(item.id, id, "The add-on is in control");
+ equal(item.value, "set", "The value is set");
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+
+ item = ExtensionSettingsStore.getSetting("foo", "bar");
+ equal(item, null, "The add-on setting was removed");
+});
+
+add_task(async function test_exceptions() {
+ await ExtensionSettingsStore.initialize();
+
+ await Assert.rejects(
+ ExtensionSettingsStore.addSetting(
+ 1,
+ TEST_TYPE,
+ "key_not_a_function",
+ "val1",
+ "not a function"
+ ),
+ /initialValueCallback must be a function/,
+ "addSetting rejects with a callback that is not a function."
+ );
+});
+
+add_task(async function test_get_all_settings() {
+ await promiseStartupManager();
+
+ let testExtensions = [
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@first" } },
+ },
+ }),
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@second" } },
+ },
+ }),
+ ];
+
+ for (let extension of testExtensions) {
+ await extension.startup();
+ }
+
+ await ExtensionSettingsStore.initialize();
+
+ let items = ExtensionSettingsStore.getAllSettings("foo", "bar");
+ equal(items.length, 0, "There are no addons controlling this setting yet");
+
+ await ExtensionSettingsStore.addSetting(
+ "@first",
+ "foo",
+ "bar",
+ "set",
+ () => "not set"
+ );
+
+ items = ExtensionSettingsStore.getAllSettings("foo", "bar");
+ equal(items.length, 1, "The add-on setting has 1 addon trying to control it");
+
+ await ExtensionSettingsStore.addSetting(
+ "@second",
+ "foo",
+ "bar",
+ "setting",
+ () => "not set"
+ );
+
+ let item = ExtensionSettingsStore.getSetting("foo", "bar");
+ equal(item.id, "@second", "The second add-on is in control");
+ equal(item.value, "setting", "The second value is set");
+
+ items = ExtensionSettingsStore.getAllSettings("foo", "bar");
+ equal(
+ items.length,
+ 2,
+ "The add-on setting has 2 addons trying to control it"
+ );
+
+ await ExtensionSettingsStore.removeSetting("@first", "foo", "bar");
+
+ items = ExtensionSettingsStore.getAllSettings("foo", "bar");
+ equal(items.length, 1, "There is only 1 addon controlling this setting");
+
+ await ExtensionSettingsStore.removeSetting("@second", "foo", "bar");
+
+ items = ExtensionSettingsStore.getAllSettings("foo", "bar");
+ equal(
+ items.length,
+ 0,
+ "There is no longer any addon controlling this setting"
+ );
+
+ for (let extension of testExtensions) {
+ await extension.unload();
+ }
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js
new file mode 100644
index 0000000000..ee5eb84907
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js
@@ -0,0 +1,146 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const HISTOGRAM = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS";
+const HISTOGRAM_KEYED = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS_BY_ADDONID";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function test_telemetry() {
+ function contentScript() {
+ browser.test.sendMessage("content-script-run");
+ }
+
+ let extension1 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_end",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+ let extension2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_end",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ clearHistograms();
+
+ let process = IS_OOP ? "content" : "parent";
+ ok(
+ !(HISTOGRAM in getSnapshots(process)),
+ `No data recorded for histogram: ${HISTOGRAM}.`
+ );
+ ok(
+ !(HISTOGRAM_KEYED in getKeyedSnapshots(process)),
+ `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.`
+ );
+
+ await extension1.startup();
+ let extensionId = extension1.extension.id;
+
+ info(`Started extension with id ${extensionId}`);
+
+ ok(
+ !(HISTOGRAM in getSnapshots(process)),
+ `No data recorded for histogram after startup: ${HISTOGRAM}.`
+ );
+ ok(
+ !(HISTOGRAM_KEYED in getKeyedSnapshots(process)),
+ `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.`
+ );
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+ await extension1.awaitMessage("content-script-run");
+ await promiseTelemetryRecorded(HISTOGRAM, process, 1);
+ await promiseKeyedTelemetryRecorded(HISTOGRAM_KEYED, process, extensionId, 1);
+
+ equal(
+ valueSum(getSnapshots(process)[HISTOGRAM].values),
+ 1,
+ `Data recorded for histogram: ${HISTOGRAM}.`
+ );
+ equal(
+ valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values),
+ 1,
+ `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.`
+ );
+
+ await contentPage.close();
+ await extension1.unload();
+
+ await extension2.startup();
+ let extensionId2 = extension2.extension.id;
+
+ info(`Started extension with id ${extensionId2}`);
+
+ equal(
+ valueSum(getSnapshots(process)[HISTOGRAM].values),
+ 1,
+ `No new data recorded for histogram after extension2 startup: ${HISTOGRAM}.`
+ );
+ equal(
+ valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values),
+ 1,
+ `No new data recorded for histogram after extension2 startup: ${HISTOGRAM_KEYED} with key ${extensionId}.`
+ );
+ ok(
+ !(extensionId2 in getKeyedSnapshots(process)[HISTOGRAM_KEYED]),
+ `No data recorded for histogram after startup: ${HISTOGRAM_KEYED} with key ${extensionId2}.`
+ );
+
+ contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+ await extension2.awaitMessage("content-script-run");
+ await promiseTelemetryRecorded(HISTOGRAM, process, 2);
+ await promiseKeyedTelemetryRecorded(
+ HISTOGRAM_KEYED,
+ process,
+ extensionId2,
+ 1
+ );
+
+ equal(
+ valueSum(getSnapshots(process)[HISTOGRAM].values),
+ 2,
+ `Data recorded for histogram: ${HISTOGRAM}.`
+ );
+ equal(
+ valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values),
+ 1,
+ `No new data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.`
+ );
+ equal(
+ valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId2].values),
+ 1,
+ `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId2}.`
+ );
+
+ await contentPage.close();
+ await extension2.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_page_navigated.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_page_navigated.js
new file mode 100644
index 0000000000..e573595afb
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_page_navigated.js
@@ -0,0 +1,341 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+const server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/", (request, response) => {
+ response.write(`<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <title>test webpage</title>
+ </head>
+ </html>
+ `);
+});
+
+function createTestExtPage({ script }) {
+ return `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="${script}"></script>
+ </head>
+ </html>
+ `;
+}
+
+function createTestExtPageScript(name) {
+ return `(${async function (pageName) {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.log(
+ `${pageName} got a webRequest.onBeforeRequest event: ${details.url}`
+ );
+ browser.test.sendMessage(`event-received:${pageName}`);
+ },
+ { urls: ["http://example.com/request*"] }
+ );
+
+ // Calling an API implemented in the parent process to make sure
+ // the webRequest.onBeforeRequest listener is got registered in
+ // the parent process by the time the test is going to expect that
+ // listener to intercept a test web request.
+ await browser.runtime.getBrowserInfo();
+ browser.test.sendMessage(`page-loaded:${pageName}`);
+ }})("${name}");`;
+}
+
+const getExtensionContextIdAndURL = extensionId => {
+ const { ExtensionProcessScript } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionProcessScript.sys.mjs"
+ );
+ let extWindow = this.content.window;
+ let extChild = ExtensionProcessScript.getExtensionChild(extensionId);
+
+ let contextIds = [];
+ let contextURLs = [];
+ for (let ctx of extChild.views) {
+ if (ctx.contentWindow === extWindow) {
+ // Only one is expected, but we collect details from all
+ // the ones that match to make sure the test will fails
+ // in case there are unexpected multiple extension contexts
+ // associated to the same contentWindow.
+ contextIds.push(ctx.contextId);
+ contextURLs.push(ctx.contentWindow.location.href);
+ }
+ }
+ return { contextIds, contextURLs };
+};
+
+const getExtensionContextStatusByContextId = (
+ extensionId,
+ extPageContextId
+) => {
+ const { ExtensionProcessScript } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionProcessScript.sys.mjs"
+ );
+ let extChild = ExtensionProcessScript.getExtensionChild(extensionId);
+
+ let context;
+ for (let ctx of extChild.views) {
+ if (ctx.contextId === extPageContextId) {
+ context = ctx;
+ }
+ }
+ return context?.active;
+};
+
+add_task(async function test_extension_page_sameprocess_navigation() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "http://example.com/*"],
+ },
+ files: {
+ "extpage1.html": createTestExtPage({ script: "extpage1.js" }),
+ "extpage1.js": createTestExtPageScript("extpage1"),
+ "extpage2.html": createTestExtPage({ script: "extpage2.js" }),
+ "extpage2.js": createTestExtPageScript("extpage2"),
+ },
+ });
+
+ await extension.startup();
+
+ const policy = WebExtensionPolicy.getByID(extension.id);
+
+ const extPageURL1 = policy.extension.baseURI.resolve("extpage1.html");
+ const extPageURL2 = policy.extension.baseURI.resolve("extpage2.html");
+
+ info("Opening extension page in a first browser element");
+ const extPage = await ExtensionTestUtils.loadContentPage(extPageURL1);
+ await extension.awaitMessage("page-loaded:extpage1");
+
+ const { contextIds, contextURLs } = await extPage.spawn(
+ [extension.id],
+ getExtensionContextIdAndURL
+ );
+
+ Assert.deepEqual(
+ contextURLs,
+ [extPageURL1],
+ `Found an extension context with the expected page url`
+ );
+
+ ok(
+ contextIds[0],
+ `Found an extension context with contextId ${contextIds[0]}`
+ );
+ ok(
+ contextIds.length,
+ `There should be only one extension context for a given content window, found ${contextIds.length}`
+ );
+
+ const [contextId] = contextIds;
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com",
+ "http://example.com/request1"
+ );
+ await extension.awaitMessage("event-received:extpage1");
+
+ info("Load a second extension page in the same browser element");
+ await extPage.loadURL(extPageURL2);
+ await extension.awaitMessage("page-loaded:extpage2");
+
+ let active;
+
+ let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => {
+ // We only expect extpage2 to be able to receive API events.
+ await ExtensionTestUtils.fetch(
+ "http://example.com",
+ "http://example.com/request2"
+ );
+ await extension.awaitMessage("event-received:extpage2");
+
+ active = await extPage.spawn(
+ [extension.id, contextId],
+ getExtensionContextStatusByContextId
+ );
+ });
+
+ if (
+ Services.appinfo.sessionHistoryInParent &&
+ WebExtensionPolicy.isExtensionProcess
+ ) {
+ // When the extension are running in the main process while the webpages run
+ // in a separate child process, the extension page doesn't enter the BFCache
+ // because nsFrameLoader::changeRemotenessCommon bails out due to retainPaint
+ // being computed as true (see
+ // https://searchfox.org/mozilla-central/rev/24c1cdc33ccce692612276cd0d3e9a44f6c22fd3/dom/base/nsFrameLoaderOwner.cpp#185-196
+ // ).
+ equal(active, undefined, "extension page context should not exist anymore");
+ } else {
+ equal(
+ active,
+ false,
+ "extension page context is expected to be inactive while moved into the BFCache"
+ );
+ }
+
+ if (typeof active === "boolean") {
+ AddonTestUtils.checkMessages(
+ messages,
+ {
+ forbidden: [
+ // We should not have tried to deserialize the event data for the extension page
+ // that got moved into the BFCache (See Bug 1499129).
+ {
+ message:
+ /StructureCloneHolder.deserialize: Argument 1 is not an object/,
+ },
+ ],
+ expected: [
+ // If the extension page is expected to be in the BFCache, then we expect to see
+ // a warning message logged for the ignored listener.
+ {
+ message:
+ /Ignored listener for inactive context .* path=webRequest.onBeforeRequest/,
+ },
+ ],
+ },
+ "Expect no StructureCloneHolder error due to trying to send the event to inactive context"
+ );
+ }
+
+ await extPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_extension_page_context_navigated_to_web_page() {
+ const extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "extpage.html": createTestExtPage({ script: "extpage.js" }),
+ "extpage.js": function () {
+ dump("loaded extension page\n");
+ window.addEventListener(
+ "pageshow",
+ () => {
+ browser.test.log("Extension page got a pageshow event");
+ browser.test.sendMessage("extpage:pageshow");
+ },
+ { once: true }
+ );
+ window.addEventListener(
+ "pagehide",
+ () => {
+ browser.test.log("Extension page got a pagehide event");
+ browser.test.sendMessage("extpage:pagehide");
+ },
+ { once: true }
+ );
+ },
+ },
+ });
+
+ await extension.startup();
+
+ const policy = WebExtensionPolicy.getByID(extension.id);
+
+ const extPageURL = policy.extension.baseURI.resolve("extpage.html");
+ const webPageURL = "http://example.com/";
+
+ info("Opening extension page in a browser element");
+ const extPage = await ExtensionTestUtils.loadContentPage(extPageURL);
+ await extension.awaitMessage("extpage:pageshow");
+
+ const { contextIds, contextURLs } = await extPage.spawn(
+ [extension.id],
+ getExtensionContextIdAndURL
+ );
+
+ Assert.deepEqual(
+ contextURLs,
+ [extPageURL],
+ `Found an extension context with the expected page url`
+ );
+
+ ok(
+ contextIds[0],
+ `Found an extension context with contextId ${contextIds[0]}`
+ );
+ ok(
+ contextIds.length,
+ `There should be only one extension context for a given content window, found ${contextIds.length}`
+ );
+
+ const [contextId] = contextIds;
+
+ info("Load a webpage in the same browser element");
+ await extPage.loadURL(webPageURL);
+ await extension.awaitMessage("extpage:pagehide");
+
+ info("Open extension page in a second browser element");
+ const extPage2 = await ExtensionTestUtils.loadContentPage(extPageURL);
+ await extension.awaitMessage("extpage:pageshow");
+
+ let active = await extPage2.spawn(
+ [extension.id, contextId],
+ getExtensionContextStatusByContextId
+ );
+
+ if (WebExtensionPolicy.isExtensionProcess) {
+ // When the extension are running in the main process while the webpages run
+ // in a separate child process, the extension page doesn't enter the BFCache
+ // because nsFrameLoader::changeRemotenessCommon bails out due to retainPaint
+ // being computed as true (see
+ // https://searchfox.org/mozilla-central/rev/24c1cdc33ccce692612276cd0d3e9a44f6c22fd3/dom/base/nsFrameLoaderOwner.cpp#185-196
+ // ).
+ equal(active, undefined, "extension page context should not exist anymore");
+ } else if (Services.appinfo.sessionHistoryInParent) {
+ // When SHIP is enabled and the extensions runs in their own child extension
+ // process, the BFCache is managed entirely from the parent process and the
+ // extension page is expected to be able to enter the BFCache.
+ equal(
+ active,
+ false,
+ "extension page context is expected to be inactive while moved into the BFCache"
+ );
+ } else {
+ // With the extension running in a separate child process but fission disabled,
+ // we expect the extension page to don't enter the BFCache.
+ equal(active, undefined, "extension page context should not exist anymore");
+ }
+
+ if (active === false) {
+ info(
+ "Navigating to more web pages to confirm the extension page have been evicted from the BFCache"
+ );
+ for (let i = 2; i < 5; i++) {
+ const url = `${webPageURL}/page${i}`;
+ info(`Navigating to ${url}`);
+ await extPage.loadURL(url);
+ }
+ equal(
+ await extPage2.spawn(
+ [extension.id, contextId],
+ getExtensionContextStatusByContextId
+ ),
+ undefined,
+ "extension page context should have been evicted"
+ );
+ }
+
+ info("Cleanup and exit test");
+
+ await Promise.all([
+ extPage.close(),
+ extPage2.close(),
+ extension.awaitMessage("extpage:pagehide"),
+ ]);
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js
new file mode 100644
index 0000000000..abd81a7ce3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js
@@ -0,0 +1,46 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { ExtensionTestCommon } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionTestCommon.sys.mjs"
+);
+
+add_task(async function extension_startup_early_error() {
+ const EXTENSION_ID = "@extension-with-package-error";
+ let extension = ExtensionTestCommon.generate({
+ manifest: {
+ browser_specific_settings: { gecko: { id: EXTENSION_ID } },
+ },
+ });
+
+ extension.initLocale = async function () {
+ // Simulate error that happens during startup.
+ extension.packagingError("dummy error");
+ };
+
+ let startupPromise = extension.startup();
+
+ let policy = WebExtensionPolicy.getByID(EXTENSION_ID);
+ ok(policy, "WebExtensionPolicy instantiated at startup");
+ let readyPromise = policy.readyPromise;
+ ok(readyPromise, "WebExtensionPolicy.readyPromise is set");
+
+ await Assert.rejects(
+ startupPromise,
+ /dummy error/,
+ "Extension with packaging error should fail to load"
+ );
+
+ Assert.equal(
+ WebExtensionPolicy.getByID(EXTENSION_ID),
+ null,
+ "WebExtensionPolicy should be unregistered"
+ );
+
+ Assert.equal(
+ await readyPromise,
+ null,
+ "policy.readyPromise should be resolved with null"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js
new file mode 100644
index 0000000000..b65c772174
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js
@@ -0,0 +1,87 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const HISTOGRAM = "WEBEXT_EXTENSION_STARTUP_MS";
+const HISTOGRAM_KEYED = "WEBEXT_EXTENSION_STARTUP_MS_BY_ADDONID";
+
+function processSnapshot(snapshot) {
+ return snapshot.sum > 0;
+}
+
+function processKeyedSnapshot(snapshot) {
+ let res = {};
+ for (let key of Object.keys(snapshot)) {
+ res[key] = snapshot[key].sum > 0;
+ }
+ return res;
+}
+
+add_task(async function test_telemetry() {
+ let extension1 = ExtensionTestUtils.loadExtension({});
+ let extension2 = ExtensionTestUtils.loadExtension({});
+
+ clearHistograms();
+
+ assertHistogramEmpty(HISTOGRAM);
+ assertKeyedHistogramEmpty(HISTOGRAM_KEYED);
+
+ await extension1.startup();
+
+ assertHistogramSnapshot(
+ HISTOGRAM,
+ { processSnapshot, expectedValue: true },
+ `Data recorded for first extension for histogram: ${HISTOGRAM}.`
+ );
+
+ assertHistogramSnapshot(
+ HISTOGRAM_KEYED,
+ {
+ keyed: true,
+ processSnapshot: processKeyedSnapshot,
+ expectedValue: {
+ [extension1.extension.id]: true,
+ },
+ },
+ `Data recorded for first extension for histogram ${HISTOGRAM_KEYED}`
+ );
+
+ let histogram = Services.telemetry.getHistogramById(HISTOGRAM);
+ let histogramKeyed =
+ Services.telemetry.getKeyedHistogramById(HISTOGRAM_KEYED);
+ let histogramSum = histogram.snapshot().sum;
+ let histogramSumExt1 = histogramKeyed.snapshot()[extension1.extension.id].sum;
+
+ await extension2.startup();
+
+ assertHistogramSnapshot(
+ HISTOGRAM,
+ {
+ processSnapshot: snapshot => snapshot.sum > histogramSum,
+ expectedValue: true,
+ },
+ `Data recorded for second extension for histogram: ${HISTOGRAM}.`
+ );
+
+ assertHistogramSnapshot(
+ HISTOGRAM_KEYED,
+ {
+ keyed: true,
+ processSnapshot: processKeyedSnapshot,
+ expectedValue: {
+ [extension1.extension.id]: true,
+ [extension2.extension.id]: true,
+ },
+ },
+ `Data recorded for second extension for histogram ${HISTOGRAM_KEYED}`
+ );
+
+ equal(
+ histogramKeyed.snapshot()[extension1.extension.id].sum,
+ histogramSumExt1,
+ `Data recorder for first extension is unchanged on the keyed histogram ${HISTOGRAM_KEYED}`
+ );
+
+ await extension1.unload();
+ await extension2.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js b/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js
new file mode 100644
index 0000000000..c05188cd38
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js
@@ -0,0 +1,193 @@
+"use strict";
+
+const FILE_DUMMY_URL = Services.io.newFileURI(
+ do_get_file("data/dummy_page.html")
+).spec;
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+// XHR/fetch from content script to the page itself is allowed.
+add_task(async function content_script_xhr_to_self() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["file:///*"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+ files: {
+ "content_script.js": async () => {
+ let response = await fetch(document.URL);
+ browser.test.assertEq(200, response.status, "expected load");
+ let responseText = await response.text();
+ browser.test.assertTrue(
+ responseText.includes("<p>Page</p>"),
+ `expected file content in response of ${response.url}`
+ );
+
+ // Now with content.fetch:
+ response = await content.fetch(document.URL);
+ browser.test.assertEq(200, response.status, "expected load (content)");
+
+ browser.test.sendMessage("done");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(FILE_DUMMY_URL);
+ await extension.awaitMessage("done");
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+// XHR/fetch for other file is not allowed, even with file://-permissions.
+add_task(async function content_script_xhr_to_other_file_not_allowed() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["file:///*"],
+ content_scripts: [
+ {
+ matches: ["file:///*"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+ files: {
+ "content_script.js": async () => {
+ let otherFileUrl = document.URL.replace(
+ "dummy_page.html",
+ "file_sample.html"
+ );
+ let x = new XMLHttpRequest();
+ x.open("GET", otherFileUrl);
+ await new Promise(resolve => {
+ x.onloadend = resolve;
+ x.send();
+ });
+ browser.test.assertEq(0, x.status, "expected error");
+ browser.test.assertEq("", x.responseText, "request should fail");
+
+ // Now with content.XMLHttpRequest.
+ x = new content.XMLHttpRequest();
+ x.open("GET", otherFileUrl);
+ x.onloadend = () => {
+ browser.test.assertEq(0, x.status, "expected error (content)");
+ browser.test.sendMessage("done");
+ };
+ x.send();
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(FILE_DUMMY_URL);
+ await extension.awaitMessage("done");
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+// "file://" permission does not grant access to files in the extension page.
+add_task(async function file_access_from_extension_page_not_allowed() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["file:///*"],
+ description: FILE_DUMMY_URL,
+ },
+ async background() {
+ const FILE_DUMMY_URL = browser.runtime.getManifest().description;
+
+ await browser.test.assertRejects(
+ fetch(FILE_DUMMY_URL),
+ /NetworkError when attempting to fetch resource/,
+ "block request to file from background page despite file permission"
+ );
+
+ // Regression test for bug 1420296 .
+ await browser.test.assertRejects(
+ fetch(FILE_DUMMY_URL, { mode: "same-origin" }),
+ /NetworkError when attempting to fetch resource/,
+ "block request to file from background page despite 'same-origin' mode"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+});
+
+// webRequest listeners should see subresource requests from file:-principals.
+add_task(async function webRequest_script_request_from_file_principals() {
+ // Extension without file:-permission should not see the request.
+ let extensionWithoutFilePermission = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://example.net/", "webRequest"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.fail(`Unexpected request from ${details.originUrl}`);
+ },
+ { urls: ["http://example.net/intercept_by_webRequest.js"] }
+ );
+ },
+ });
+
+ // Extension with <all_urls> (which matches the resource URL at example.net
+ // and the origin at file://*/*) can see the request.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["<all_urls>", "webRequest", "webRequestBlocking"],
+ web_accessible_resources: ["testDONE.html"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ ({ originUrl }) => {
+ browser.test.assertTrue(
+ /^file:.*file_do_load_script_subresource.html/.test(originUrl),
+ `expected script to be loaded from a local file (${originUrl})`
+ );
+ let redirectUrl = browser.runtime.getURL("testDONE.html");
+ return {
+ redirectUrl: `data:text/javascript,location.href='${redirectUrl}';`,
+ };
+ },
+ { urls: ["http://example.net/intercept_by_webRequest.js"] },
+ ["blocking"]
+ );
+ },
+ files: {
+ "testDONE.html": `<!DOCTYPE html><script src="testDONE.js"></script>`,
+ "testDONE.js"() {
+ browser.test.sendMessage("webRequest_redirect_completed");
+ },
+ },
+ });
+
+ await extensionWithoutFilePermission.startup();
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ Services.io.newFileURI(
+ do_get_file("data/file_do_load_script_subresource.html")
+ ).spec
+ );
+ await extension.awaitMessage("webRequest_redirect_completed");
+ await contentPage.close();
+
+ await extension.unload();
+ await extensionWithoutFilePermission.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js
new file mode 100644
index 0000000000..fa2759b7f0
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js
@@ -0,0 +1,205 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+let getExtension = () => {
+ return ExtensionTestUtils.loadExtension({
+ background: async function () {
+ const runningListener = isRunning => {
+ if (isRunning) {
+ browser.test.sendMessage("started");
+ } else {
+ browser.test.sendMessage("stopped");
+ }
+ };
+
+ browser.test.onMessage.addListener(async (message, data) => {
+ let result;
+ switch (message) {
+ case "start":
+ result = await browser.geckoProfiler.start({
+ bufferSize: 10000,
+ windowLength: 20,
+ interval: 0.5,
+ features: ["js"],
+ threads: ["GeckoMain"],
+ });
+ browser.test.assertEq(undefined, result, "start returns nothing.");
+ break;
+ case "stop":
+ result = await browser.geckoProfiler.stop();
+ browser.test.assertEq(undefined, result, "stop returns nothing.");
+ break;
+ case "pause":
+ result = await browser.geckoProfiler.pause();
+ browser.test.assertEq(undefined, result, "pause returns nothing.");
+ browser.test.sendMessage("paused");
+ break;
+ case "resume":
+ result = await browser.geckoProfiler.resume();
+ browser.test.assertEq(undefined, result, "resume returns nothing.");
+ browser.test.sendMessage("resumed");
+ break;
+ case "test profile":
+ result = await browser.geckoProfiler.getProfile();
+ browser.test.assertTrue(
+ "libs" in result,
+ "The profile contains libs."
+ );
+ browser.test.assertTrue(
+ "meta" in result,
+ "The profile contains meta."
+ );
+ browser.test.assertTrue(
+ "threads" in result,
+ "The profile contains threads."
+ );
+ browser.test.assertTrue(
+ result.threads.some(t => t.name == "GeckoMain"),
+ "The profile contains a GeckoMain thread."
+ );
+ browser.test.sendMessage("tested profile");
+ break;
+ case "test dump to file":
+ try {
+ await browser.geckoProfiler.dumpProfileToFile(data.fileName);
+ browser.test.sendMessage("tested dump to file", {});
+ } catch (e) {
+ browser.test.sendMessage("tested dump to file", {
+ error: e.message,
+ });
+ }
+ break;
+ case "test profile as array buffer":
+ let arrayBuffer =
+ await browser.geckoProfiler.getProfileAsArrayBuffer();
+ browser.test.assertTrue(
+ arrayBuffer.byteLength >= 2,
+ "The profile array buffer contains data."
+ );
+ let textDecoder = new TextDecoder();
+ let profile = JSON.parse(textDecoder.decode(arrayBuffer));
+ browser.test.assertTrue(
+ "libs" in profile,
+ "The profile contains libs."
+ );
+ browser.test.assertTrue(
+ "meta" in profile,
+ "The profile contains meta."
+ );
+ browser.test.assertTrue(
+ "threads" in profile,
+ "The profile contains threads."
+ );
+ browser.test.assertTrue(
+ profile.threads.some(t => t.name == "GeckoMain"),
+ "The profile contains a GeckoMain thread."
+ );
+ browser.test.sendMessage("tested profile as array buffer");
+ break;
+ case "remove runningListener":
+ browser.geckoProfiler.onRunning.removeListener(runningListener);
+ browser.test.sendMessage("removed runningListener");
+ break;
+ }
+ });
+
+ browser.test.sendMessage("ready");
+
+ browser.geckoProfiler.onRunning.addListener(runningListener);
+ },
+
+ manifest: {
+ permissions: ["geckoProfiler"],
+ browser_specific_settings: {
+ gecko: {
+ id: "profilertest@mozilla.com",
+ },
+ },
+ },
+ });
+};
+
+let verifyProfileData = profile => {
+ ok("libs" in profile, "The profile contains libs.");
+ ok("meta" in profile, "The profile contains meta.");
+ ok("threads" in profile, "The profile contains threads.");
+ ok(
+ profile.threads.some(t => t.name == "GeckoMain"),
+ "The profile contains a GeckoMain thread."
+ );
+};
+
+add_task(async function testProfilerControl() {
+ const acceptedExtensionIdsPref =
+ "extensions.geckoProfiler.acceptedExtensionIds";
+ Services.prefs.setCharPref(
+ acceptedExtensionIdsPref,
+ "profilertest@mozilla.com"
+ );
+
+ let extension = getExtension();
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ await extension.awaitMessage("stopped");
+
+ extension.sendMessage("start");
+ await extension.awaitMessage("started");
+
+ extension.sendMessage("test profile");
+ await extension.awaitMessage("tested profile");
+
+ const profilerPath = PathUtils.join(PathUtils.profileDir, "profiler");
+ let data, fileName, targetPath;
+
+ // test with file name only
+ fileName = "bar.profile";
+ targetPath = PathUtils.join(profilerPath, fileName);
+ extension.sendMessage("test dump to file", { fileName });
+ data = await extension.awaitMessage("tested dump to file");
+ equal(data.error, undefined, "No error thrown");
+ ok(await IOUtils.exists(targetPath), "Saved gecko profile exists.");
+ verifyProfileData(await IOUtils.readJSON(targetPath));
+
+ // test overwriting the formerly created file
+ extension.sendMessage("test dump to file", { fileName });
+ data = await extension.awaitMessage("tested dump to file");
+ equal(data.error, undefined, "No error thrown");
+ ok(await IOUtils.exists(targetPath), "Saved gecko profile exists.");
+ verifyProfileData(await IOUtils.readJSON(targetPath));
+
+ // test with a POSIX path, which is not allowed
+ fileName = "foo/bar.profile";
+ targetPath = PathUtils.join(profilerPath, ...fileName.split("/"));
+ extension.sendMessage("test dump to file", { fileName });
+ data = await extension.awaitMessage("tested dump to file");
+ equal(data.error, "Path cannot contain a subdirectory.");
+ ok(!(await IOUtils.exists(targetPath)), "Gecko profile hasn't been saved.");
+
+ // test with a non POSIX path which is not allowed
+ fileName = "foo\\bar.profile";
+ targetPath = PathUtils.join(profilerPath, ...fileName.split("\\"));
+ extension.sendMessage("test dump to file", { fileName });
+ data = await extension.awaitMessage("tested dump to file");
+ equal(data.error, "Path cannot contain a subdirectory.");
+ ok(!(await IOUtils.exists(targetPath)), "Gecko profile hasn't been saved.");
+
+ extension.sendMessage("test profile as array buffer");
+ await extension.awaitMessage("tested profile as array buffer");
+
+ extension.sendMessage("pause");
+ await extension.awaitMessage("paused");
+
+ extension.sendMessage("resume");
+ await extension.awaitMessage("resumed");
+
+ extension.sendMessage("stop");
+ await extension.awaitMessage("stopped");
+
+ extension.sendMessage("remove runningListener");
+ await extension.awaitMessage("removed runningListener");
+
+ await extension.unload();
+
+ Services.prefs.clearUserPref(acceptedExtensionIdsPref);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js
new file mode 100644
index 0000000000..6d96731a3c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js
@@ -0,0 +1,68 @@
+"use strict";
+
+add_task(async function () {
+ // The startupCache is removed whenever the buildid changes by code that runs
+ // during Firefox startup but not during xpcshell startup, remove it by hand
+ // before running this test to avoid failures with --conditioned-profile
+ let file = PathUtils.join(
+ Services.dirsvc.get("ProfLD", Ci.nsIFile).path,
+ "startupCache",
+ "webext.sc.lz4"
+ );
+ await IOUtils.remove(file, { ignoreAbsent: true });
+
+ const acceptedExtensionIdsPref =
+ "extensions.geckoProfiler.acceptedExtensionIds";
+ Services.prefs.setCharPref(
+ acceptedExtensionIdsPref,
+ "profilertest@mozilla.com"
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: () => {
+ browser.test.sendMessage(
+ "features",
+ Object.values(browser.geckoProfiler.ProfilerFeature)
+ );
+ },
+ manifest: {
+ permissions: ["geckoProfiler"],
+ browser_specific_settings: {
+ gecko: {
+ id: "profilertest@mozilla.com",
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+ let acceptedFeatures = await extension.awaitMessage("features");
+ await extension.unload();
+
+ Services.prefs.clearUserPref(acceptedExtensionIdsPref);
+
+ const allFeaturesAcceptedByProfiler = Services.profiler.GetAllFeatures();
+ ok(
+ allFeaturesAcceptedByProfiler.length >= 2,
+ "Either we've massively reduced the profiler's feature set, or something is wrong."
+ );
+
+ // Check that the list of available values in the ProfilerFeature enum
+ // matches the list of features supported by the profiler.
+ for (const feature of allFeaturesAcceptedByProfiler) {
+ // If this fails, check the lists in {,Base}ProfilerState.h and geckoProfiler.json.
+ ok(
+ acceptedFeatures.includes(feature),
+ `The schema of the geckoProfiler.start() method should accept the "${feature}" feature.`
+ );
+ }
+ for (const feature of acceptedFeatures) {
+ // If this fails, check the lists in {,Base}ProfilerState.h and geckoProfiler.json.
+ ok(
+ // Bug 1594566 - ignore Responsiveness until the extension is updated
+ allFeaturesAcceptedByProfiler.includes(feature) ||
+ feature == "responsiveness",
+ `The schema of the geckoProfiler.start() method mentions a "${feature}" feature which is not supported by the profiler.`
+ );
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js b/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js
new file mode 100644
index 0000000000..b9048787d5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js
@@ -0,0 +1,64 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.runtime.onMessage.addListener(([url1, url2]) => {
+ let url3 = browser.runtime.getURL("test_file.html");
+ let url4 = browser.extension.getURL("test_file.html");
+
+ browser.test.assertTrue(url1 !== undefined, "url1 defined");
+
+ browser.test.assertTrue(
+ url1.startsWith("moz-extension://"),
+ "url1 has correct scheme"
+ );
+ browser.test.assertTrue(
+ url1.endsWith("test_file.html"),
+ "url1 has correct leaf name"
+ );
+
+ browser.test.assertEq(url1, url2, "url2 matches");
+ browser.test.assertEq(url1, url3, "url3 matches");
+ browser.test.assertEq(url1, url4, "url4 matches");
+
+ browser.test.notifyPass("geturl");
+ });
+ },
+
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js"() {
+ let url1 = browser.runtime.getURL("test_file.html");
+ let url2 = browser.extension.getURL("test_file.html");
+ browser.runtime.sendMessage([url1, url2]);
+ },
+ },
+ });
+ // Turn off warning as errors to pass for deprecated APIs
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ await extension.awaitFinish("geturl");
+
+ await contentPage.close();
+
+ await extension.unload();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js b/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js
new file mode 100644
index 0000000000..048e675a3e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js
@@ -0,0 +1,571 @@
+"use strict";
+
+const { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+var originalReqLocales = Services.locale.requestedLocales;
+
+registerCleanupFunction(() => {
+ Preferences.reset("intl.accept_languages");
+ Services.locale.requestedLocales = originalReqLocales;
+});
+
+add_task(async function test_i18n() {
+ function runTests(assertEq) {
+ let _ = browser.i18n.getMessage.bind(browser.i18n);
+
+ let url = browser.runtime.getURL("/");
+ assertEq(
+ url,
+ `moz-extension://${_("@@extension_id")}/`,
+ "@@extension_id builtin message"
+ );
+
+ assertEq("Foo.", _("Foo"), "Simple message in selected locale.");
+
+ assertEq("(bar)", _("bar"), "Simple message fallback in default locale.");
+
+ assertEq("", _("some-unknown-locale-string"), "Unknown locale string.");
+
+ assertEq("", _("@@unknown_builtin_string"), "Unknown built-in string.");
+ assertEq(
+ "",
+ _("@@bidi_unknown_builtin_string"),
+ "Unknown built-in bidi string."
+ );
+
+ assertEq("Føo.", _("Föo"), "Multi-byte message in selected locale.");
+
+ let substitutions = [];
+ substitutions[4] = "5";
+ substitutions[13] = "14";
+
+ assertEq(
+ "'$0' '14' '' '5' '$$$$' '$'.",
+ _("basic_substitutions", substitutions),
+ "Basic numeric substitutions"
+ );
+
+ assertEq(
+ "'$0' '' 'just a string' '' '$$$$' '$'.",
+ _("basic_substitutions", "just a string"),
+ "Basic numeric substitutions, with non-array value"
+ );
+
+ let values = _("named_placeholder_substitutions", [
+ "(subst $1 $2)",
+ "(2 $1 $2)",
+ ]).split("\n");
+
+ assertEq(
+ "_foo_ (subst $1 $2) _bar_",
+ values[0],
+ "Named and numeric substitution"
+ );
+
+ assertEq(
+ "(2 $1 $2)",
+ values[1],
+ "Numeric substitution amid named placeholders"
+ );
+
+ assertEq("$bad name$", values[2], "Named placeholder with invalid key");
+
+ assertEq("", values[3], "Named placeholder with an invalid value");
+
+ assertEq(
+ "Accepted, but shouldn't break.",
+ values[4],
+ "Named placeholder with a strange content value"
+ );
+
+ assertEq("$foo", values[5], "Non-placeholder token that should be ignored");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ default_locale: "jp",
+
+ content_scripts: [
+ { matches: ["http://*/*/file_sample.html"], js: ["content.js"] },
+ ],
+ },
+
+ files: {
+ "_locales/en_US/messages.json": {
+ foo: {
+ message: "Foo.",
+ description: "foo",
+ },
+
+ föo: {
+ message: "Føo.",
+ description: "foo",
+ },
+
+ basic_substitutions: {
+ message: "'$0' '$14' '$1' '$5' '$$$$$' '$$'.",
+ description: "foo",
+ },
+
+ Named_placeholder_substitutions: {
+ message:
+ "$Foo$\n$2\n$bad name$\n$bad_value$\n$bad_content_value$\n$foo",
+ description: "foo",
+ placeholders: {
+ foO: {
+ content: "_foo_ $1 _bar_",
+ description: "foo",
+ },
+
+ "bad name": {
+ content: "Nope.",
+ description: "bad name",
+ },
+
+ bad_value: "Nope.",
+
+ bad_content_value: {
+ content: ["Accepted, but shouldn't break."],
+ description: "bad value",
+ },
+ },
+ },
+
+ broken_placeholders: {
+ message: "$broken$",
+ description: "broken placeholders",
+ placeholders: "foo.",
+ },
+ },
+
+ "_locales/jp/messages.json": {
+ foo: {
+ message: "(foo)",
+ description: "foo",
+ },
+
+ bar: {
+ message: "(bar)",
+ description: "bar",
+ },
+ },
+
+ "content.js":
+ "new " +
+ function (runTestsFn) {
+ runTestsFn((...args) => {
+ browser.runtime.sendMessage(["assertEq", ...args]);
+ });
+
+ browser.runtime.sendMessage(["content-script-finished"]);
+ } +
+ `(${runTests})`,
+ },
+
+ background:
+ "new " +
+ function (runTestsFn) {
+ browser.runtime.onMessage.addListener(([msg, ...args]) => {
+ if (msg == "assertEq") {
+ browser.test.assertEq(...args);
+ } else {
+ browser.test.sendMessage(msg, ...args);
+ }
+ });
+
+ runTestsFn(browser.test.assertEq.bind(browser.test));
+ } +
+ `(${runTests})`,
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+ await extension.awaitMessage("content-script-finished");
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_i18n_negotiation() {
+ function runTests(expected) {
+ let _ = browser.i18n.getMessage.bind(browser.i18n);
+
+ browser.test.assertEq(expected, _("foo"), "Got expected message");
+ }
+
+ let extensionData = {
+ manifest: {
+ default_locale: "en_US",
+
+ content_scripts: [
+ { matches: ["http://*/*/file_sample.html"], js: ["content.js"] },
+ ],
+ },
+
+ files: {
+ "_locales/en_US/messages.json": {
+ foo: {
+ message: "English.",
+ description: "foo",
+ },
+ },
+
+ "_locales/jp/messages.json": {
+ foo: {
+ message: "\u65e5\u672c\u8a9e",
+ description: "foo",
+ },
+ },
+
+ "content.js":
+ "new " +
+ function (runTestsFn) {
+ browser.test.onMessage.addListener(expected => {
+ runTestsFn(expected);
+
+ browser.test.sendMessage("content-script-finished");
+ });
+ browser.test.sendMessage("content-ready");
+ } +
+ `(${runTests})`,
+ },
+
+ background:
+ "new " +
+ function (runTestsFn) {
+ browser.test.onMessage.addListener(expected => {
+ runTestsFn(expected);
+
+ browser.test.sendMessage("background-script-finished");
+ });
+ } +
+ `(${runTests})`,
+ };
+
+ // At the moment extension language negotiation is tied to Firefox language
+ // negotiation result. That means that to test an extension in `fr`, we need
+ // to mock `fr` being available in Firefox and then request it.
+ //
+ // In the future, we should provide some way for tests to decouple their
+ // language selection from that of Firefox.
+ Services.locale.availableLocales = ["en-US", "fr", "jp"];
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ for (let [lang, msg] of [
+ ["en-US", "English."],
+ ["jp", "\u65e5\u672c\u8a9e"],
+ ]) {
+ Services.locale.requestedLocales = [lang];
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("content-ready");
+
+ extension.sendMessage(msg);
+ await extension.awaitMessage("background-script-finished");
+ await extension.awaitMessage("content-script-finished");
+
+ await extension.unload();
+ }
+ Services.locale.requestedLocales = originalReqLocales;
+
+ await contentPage.close();
+});
+
+add_task(async function test_get_accept_languages() {
+ function checkResults(source, results, expected) {
+ browser.test.assertEq(
+ expected.length,
+ results.length,
+ `got expected number of languages in ${source}`
+ );
+ results.forEach((lang, index) => {
+ browser.test.assertEq(
+ expected[index],
+ lang,
+ `got expected language in ${source}`
+ );
+ });
+ }
+
+ function background(checkResultsFn) {
+ browser.test.onMessage.addListener(([msg, expected]) => {
+ browser.i18n.getAcceptLanguages().then(results => {
+ checkResultsFn("background", results, expected);
+
+ browser.test.sendMessage("background-done");
+ });
+ });
+ }
+
+ function content(checkResultsFn) {
+ browser.test.onMessage.addListener(([msg, expected]) => {
+ browser.i18n.getAcceptLanguages().then(results => {
+ checkResultsFn("contentScript", results, expected);
+
+ browser.test.sendMessage("content-done");
+ });
+ });
+ browser.test.sendMessage("content-loaded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ run_at: "document_start",
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ background: `(${background})(${checkResults})`,
+
+ files: {
+ "content_script.js": `(${content})(${checkResults})`,
+ },
+ });
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.startup();
+ await extension.awaitMessage("content-loaded");
+
+ // TODO bug 1765375: ", en" is missing on Android.
+ let expectedLangs =
+ AppConstants.platform == "android" ? ["en-US"] : ["en-US", "en"];
+ extension.sendMessage(["expect-results", expectedLangs]);
+ await extension.awaitMessage("background-done");
+ await extension.awaitMessage("content-done");
+
+ expectedLangs = ["en-US", "en", "fr-CA", "fr"];
+ Preferences.set("intl.accept_languages", expectedLangs.toString());
+ extension.sendMessage(["expect-results", expectedLangs]);
+ await extension.awaitMessage("background-done");
+ await extension.awaitMessage("content-done");
+ Preferences.reset("intl.accept_languages");
+
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_get_ui_language() {
+ function getResults() {
+ return {
+ getUILanguage: browser.i18n.getUILanguage(),
+ getMessage: browser.i18n.getMessage("@@ui_locale"),
+ };
+ }
+
+ function checkResults(source, results, expected) {
+ browser.test.assertEq(
+ expected,
+ results.getUILanguage,
+ `Got expected getUILanguage result in ${source}`
+ );
+ browser.test.assertEq(
+ expected,
+ results.getMessage,
+ `Got expected getMessage result in ${source}`
+ );
+ }
+
+ function background(getResultsFn, checkResultsFn) {
+ browser.test.onMessage.addListener(([msg, expected]) => {
+ checkResultsFn("background", getResultsFn(), expected);
+
+ browser.test.sendMessage("background-done");
+ });
+ }
+
+ function content(getResultsFn, checkResultsFn) {
+ browser.test.onMessage.addListener(([msg, expected]) => {
+ checkResultsFn("contentScript", getResultsFn(), expected);
+
+ browser.test.sendMessage("content-done");
+ });
+ browser.test.sendMessage("content-loaded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ run_at: "document_start",
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ background: `(${background})(${getResults}, ${checkResults})`,
+
+ files: {
+ "content_script.js": `(${content})(${getResults}, ${checkResults})`,
+ },
+ });
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.startup();
+ await extension.awaitMessage("content-loaded");
+
+ extension.sendMessage(["expect-results", "en-US"]);
+
+ await extension.awaitMessage("background-done");
+ await extension.awaitMessage("content-done");
+
+ // We don't currently have a good way to mock this.
+ if (false) {
+ Services.locale.requestedLocales = ["he"];
+
+ extension.sendMessage(["expect-results", "he"]);
+
+ await extension.awaitMessage("background-done");
+ await extension.awaitMessage("content-done");
+ }
+
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_detect_language() {
+ const af_string =
+ " aam skukuza die naam beteken hy wat skoonvee of hy wat alles onderstebo keer wysig " +
+ "bosveldkampe boskampe is kleiner afgeleë ruskampe wat oor min fasiliteite beskik daar is geen restaurante " +
+ "of winkels nie en slegs oornagbesoekers word toegelaat bateleur";
+ // String with intermixed French/English text
+ const fr_en_string =
+ "France is the largest country in Western Europe and the third-largest in Europe as a whole. " +
+ "A accès aux chiens et aux frontaux qui lui ont été il peut consulter et modifier ses collections et exporter " +
+ "Cet article concerne le pays européen aujourd’hui appelé République française. Pour d’autres usages du nom France, " +
+ "Pour une aide rapide et effective, veuiller trouver votre aide dans le menu ci-dessus." +
+ "Motoring events began soon after the construction of the first successful gasoline-fueled automobiles. The quick brown fox jumped over the lazy dog";
+
+ function checkResult(source, result, expected) {
+ browser.test.assertEq(
+ expected.isReliable,
+ result.isReliable,
+ "result.confident is true"
+ );
+ browser.test.assertEq(
+ expected.languages.length,
+ result.languages.length,
+ `result.languages contains the expected number of languages in ${source}`
+ );
+ expected.languages.forEach((lang, index) => {
+ browser.test.assertEq(
+ lang.percentage,
+ result.languages[index].percentage,
+ `element ${index} of result.languages array has the expected percentage in ${source}`
+ );
+ browser.test.assertEq(
+ lang.language,
+ result.languages[index].language,
+ `element ${index} of result.languages array has the expected language in ${source}`
+ );
+ });
+ }
+
+ function backgroundScript(checkResultFn) {
+ browser.test.onMessage.addListener(([msg, expected]) => {
+ browser.i18n.detectLanguage(msg).then(result => {
+ checkResultFn("background", result, expected);
+ browser.test.sendMessage("background-done");
+ });
+ });
+ }
+
+ function content(checkResultFn) {
+ browser.test.onMessage.addListener(([msg, expected]) => {
+ browser.i18n.detectLanguage(msg).then(result => {
+ checkResultFn("contentScript", result, expected);
+ browser.test.sendMessage("content-done");
+ });
+ });
+ browser.test.sendMessage("content-loaded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ run_at: "document_start",
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ background: `(${backgroundScript})(${checkResult})`,
+
+ files: {
+ "content_script.js": `(${content})(${checkResult})`,
+ },
+ });
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.startup();
+ await extension.awaitMessage("content-loaded");
+
+ let expected = {
+ isReliable: true,
+ languages: [
+ {
+ language: "fr",
+ percentage: 67,
+ },
+ {
+ language: "en",
+ percentage: 32,
+ },
+ ],
+ };
+ extension.sendMessage([fr_en_string, expected]);
+ await extension.awaitMessage("background-done");
+ await extension.awaitMessage("content-done");
+
+ expected = {
+ isReliable: true,
+ languages: [
+ {
+ language: "af",
+ percentage: 99,
+ },
+ ],
+ };
+ extension.sendMessage([af_string, expected]);
+ await extension.awaitMessage("background-done");
+ await extension.awaitMessage("content-done");
+
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js
new file mode 100644
index 0000000000..e02ae09e11
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js
@@ -0,0 +1,194 @@
+"use strict";
+
+const { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+const { createAppInfo, promiseShutdownManager, promiseStartupManager } =
+ AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+// Some multibyte characters. This sample was taken from the encoding/api-basics.html web platform test.
+const MULTIBYTE_STRING = "z\xA2\u6C34\uD834\uDD1E\uF8FF\uDBFF\uDFFD\uFFFE";
+let getCSS = (a, b) => `a { content: '${a}'; } b { content: '${b}'; }`;
+
+let extensionData = {
+ background: function () {
+ function backgroundFetch(url) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.overrideMimeType("text/plain");
+ xhr.open("GET", url);
+ xhr.onload = () => {
+ resolve(xhr.responseText);
+ };
+ xhr.onerror = reject;
+ xhr.send();
+ });
+ }
+
+ Promise.all([
+ backgroundFetch("foo.css"),
+ backgroundFetch("bar.CsS?x#y"),
+ backgroundFetch("foo.txt"),
+ ]).then(results => {
+ browser.test.assertEq(
+ "body { max-width: 42px; }",
+ results[0],
+ "CSS file localized"
+ );
+ browser.test.assertEq(
+ "body { max-width: 42px; }",
+ results[1],
+ "CSS file localized"
+ );
+
+ browser.test.assertEq(
+ "body { __MSG_foo__; }",
+ results[2],
+ "Text file not localized"
+ );
+
+ browser.test.notifyPass("i18n-css");
+ });
+
+ browser.test.sendMessage("ready", browser.runtime.getURL("/"));
+ },
+
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "i18n_css@mochi.test",
+ },
+ },
+
+ web_accessible_resources: [
+ "foo.css",
+ "foo.txt",
+ "locale.css",
+ "multibyte.css",
+ ],
+
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ css: ["foo.css"],
+ run_at: "document_start",
+ },
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content.js"],
+ },
+ ],
+
+ default_locale: "en",
+ },
+
+ files: {
+ "_locales/en/messages.json": JSON.stringify({
+ foo: {
+ message: "max-width: 42px",
+ description: "foo",
+ },
+ multibyteKey: {
+ message: MULTIBYTE_STRING,
+ },
+ }),
+
+ "content.js": function () {
+ let style = getComputedStyle(document.body);
+ browser.test.sendMessage("content-maxWidth", style.maxWidth);
+ },
+
+ "foo.css": "body { __MSG_foo__; }",
+ "bar.CsS": "body { __MSG_foo__; }",
+ "foo.txt": "body { __MSG_foo__; }",
+ "locale.css":
+ '* { content: "__MSG_@@ui_locale__ __MSG_@@bidi_dir__ __MSG_@@bidi_reversed_dir__ __MSG_@@bidi_start_edge__ __MSG_@@bidi_end_edge__" }',
+ "multibyte.css": getCSS("__MSG_multibyteKey__", MULTIBYTE_STRING),
+ },
+};
+
+async function test_i18n_css(options = {}) {
+ extensionData.useAddonManager = options.useAddonManager;
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+ let baseURL = await extension.awaitMessage("ready");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ let css = await contentPage.fetch(baseURL + "foo.css");
+
+ equal(
+ css,
+ "body { max-width: 42px; }",
+ "CSS file localized in mochitest scope"
+ );
+
+ let maxWidth = await extension.awaitMessage("content-maxWidth");
+
+ equal(maxWidth, "42px", "stylesheet correctly applied");
+
+ css = await contentPage.fetch(baseURL + "locale.css");
+ equal(
+ css,
+ '* { content: "en-US ltr rtl left right" }',
+ "CSS file localized in mochitest scope"
+ );
+
+ css = await contentPage.fetch(baseURL + "multibyte.css");
+ equal(
+ css,
+ getCSS(MULTIBYTE_STRING, MULTIBYTE_STRING),
+ "CSS file contains multibyte string"
+ );
+
+ await contentPage.close();
+
+ // We don't currently have a good way to mock this.
+ if (false) {
+ const DIR = "intl.l10n.pseudo";
+
+ // We don't wind up actually switching the chrome registry locale, since we
+ // don't have a chrome package for Hebrew. So just override it, and force
+ // RTL directionality.
+ const origReqLocales = Services.locale.requestedLocales;
+ Services.locale.requestedLocales = ["he"];
+ Preferences.set(DIR, "bidi");
+
+ css = await fetch(baseURL + "locale.css");
+ equal(
+ css,
+ '* { content: "he rtl ltr right left" }',
+ "CSS file localized in mochitest scope"
+ );
+
+ Services.locale.requestedLocales = origReqLocales;
+ Preferences.reset(DIR);
+ }
+
+ await extension.awaitFinish("i18n-css");
+ await extension.unload();
+}
+
+add_task(async function startup() {
+ await promiseStartupManager();
+});
+add_task(test_i18n_css);
+add_task(async function test_i18n_css_xpi() {
+ await test_i18n_css({ useAddonManager: "temporary" });
+});
+add_task(async function startup() {
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_idle.js b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js
new file mode 100644
index 0000000000..6398224f1a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js
@@ -0,0 +1,361 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+let idleService = {
+ _observers: new Set(),
+ _activity: {
+ addCalls: [],
+ removeCalls: [],
+ observerFires: [],
+ },
+ _reset: function () {
+ this._observers.clear();
+ this._activity.addCalls = [];
+ this._activity.removeCalls = [];
+ this._activity.observerFires = [];
+ },
+ _fireObservers: function (state) {
+ for (let observer of this._observers.values()) {
+ observer.observe(observer, state, null);
+ this._activity.observerFires.push(state);
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIUserIdleService"]),
+ idleTime: 19999,
+ addIdleObserver: function (observer, time) {
+ this._observers.add(observer);
+ this._activity.addCalls.push(time);
+ },
+ removeIdleObserver: function (observer, time) {
+ this._observers.delete(observer);
+ this._activity.removeCalls.push(time);
+ },
+};
+
+function checkActivity(expectedActivity) {
+ let { expectedAdd, expectedRemove, expectedFires } = expectedActivity;
+ let { addCalls, removeCalls, observerFires } = idleService._activity;
+ equal(
+ expectedAdd.length,
+ addCalls.length,
+ "idleService.addIdleObserver was called the expected number of times"
+ );
+ equal(
+ expectedRemove.length,
+ removeCalls.length,
+ "idleService.removeIdleObserver was called the expected number of times"
+ );
+ equal(
+ expectedFires.length,
+ observerFires.length,
+ "idle observer was fired the expected number of times"
+ );
+ deepEqual(
+ addCalls,
+ expectedAdd,
+ "expected interval passed to idleService.addIdleObserver"
+ );
+ deepEqual(
+ removeCalls,
+ expectedRemove,
+ "expected interval passed to idleService.removeIdleObserver"
+ );
+ deepEqual(
+ observerFires,
+ expectedFires,
+ "expected topic passed to idle observer"
+ );
+}
+
+add_task(async function setup() {
+ let fakeIdleService = MockRegistrar.register(
+ "@mozilla.org/widget/useridleservice;1",
+ idleService
+ );
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(fakeIdleService);
+ });
+});
+
+add_task(async function testQueryStateActive() {
+ function background() {
+ browser.idle.queryState(20).then(
+ status => {
+ browser.test.assertEq("active", status, "Idle status is active");
+ browser.test.notifyPass("idle");
+ },
+ err => {
+ browser.test.fail(`Error: ${err} :: ${err.stack}`);
+ browser.test.notifyFail("idle");
+ }
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("idle");
+ await extension.unload();
+});
+
+add_task(async function testQueryStateIdle() {
+ function background() {
+ browser.idle.queryState(15).then(
+ status => {
+ browser.test.assertEq("idle", status, "Idle status is idle");
+ browser.test.notifyPass("idle");
+ },
+ err => {
+ browser.test.fail(`Error: ${err} :: ${err.stack}`);
+ browser.test.notifyFail("idle");
+ }
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("idle");
+ await extension.unload();
+});
+
+add_task(async function testOnlySetDetectionInterval() {
+ function background() {
+ browser.idle.setDetectionInterval(99);
+ browser.test.sendMessage("detectionIntervalSet");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ idleService._reset();
+ await extension.startup();
+ await extension.awaitMessage("detectionIntervalSet");
+ idleService._fireObservers("idle");
+ checkActivity({ expectedAdd: [], expectedRemove: [], expectedFires: [] });
+ await extension.unload();
+});
+
+add_task(async function testSetDetectionIntervalBeforeAddingListener() {
+ function background() {
+ browser.idle.setDetectionInterval(99);
+ browser.idle.onStateChanged.addListener(newState => {
+ browser.test.assertEq(
+ "idle",
+ newState,
+ "listener fired with the expected state"
+ );
+ browser.test.sendMessage("listenerFired");
+ });
+ browser.test.sendMessage("listenerAdded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ idleService._reset();
+ await extension.startup();
+ await extension.awaitMessage("listenerAdded");
+ idleService._fireObservers("idle");
+ await extension.awaitMessage("listenerFired");
+ checkActivity({
+ expectedAdd: [99],
+ expectedRemove: [],
+ expectedFires: ["idle"],
+ });
+ // Defer unloading the extension so the asynchronous event listener
+ // reply finishes.
+ await new Promise(resolve => setTimeout(resolve, 0));
+ await extension.unload();
+});
+
+add_task(async function testSetDetectionIntervalAfterAddingListener() {
+ function background() {
+ browser.idle.onStateChanged.addListener(newState => {
+ browser.test.assertEq(
+ "idle",
+ newState,
+ "listener fired with the expected state"
+ );
+ browser.test.sendMessage("listenerFired");
+ });
+ browser.idle.setDetectionInterval(99);
+ browser.test.sendMessage("detectionIntervalSet");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ idleService._reset();
+ await extension.startup();
+ await extension.awaitMessage("detectionIntervalSet");
+ idleService._fireObservers("idle");
+ await extension.awaitMessage("listenerFired");
+ checkActivity({
+ expectedAdd: [60, 99],
+ expectedRemove: [60],
+ expectedFires: ["idle"],
+ });
+
+ // Defer unloading the extension so the asynchronous event listener
+ // reply finishes.
+ await new Promise(resolve => setTimeout(resolve, 0));
+ await extension.unload();
+});
+
+add_task(async function testOnlyAddingListener() {
+ function background() {
+ browser.idle.onStateChanged.addListener(newState => {
+ browser.test.assertEq(
+ "active",
+ newState,
+ "listener fired with the expected state"
+ );
+ browser.test.sendMessage("listenerFired");
+ });
+ browser.test.sendMessage("listenerAdded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ idleService._reset();
+ await extension.startup();
+ await extension.awaitMessage("listenerAdded");
+ idleService._fireObservers("active");
+ await extension.awaitMessage("listenerFired");
+ // check that "idle-daily" topic does not cause a listener to fire
+ idleService._fireObservers("idle-daily");
+ checkActivity({
+ expectedAdd: [60],
+ expectedRemove: [],
+ expectedFires: ["active", "idle-daily"],
+ });
+
+ // Defer unloading the extension so the asynchronous event listener
+ // reply finishes.
+ await new Promise(resolve => setTimeout(resolve, 0));
+ await extension.unload();
+});
+
+add_task(
+ { pref_set: [["extensions.eventPages.enabled", true]] },
+ async function test_idle_event_page() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["idle"],
+ background: { persistent: false },
+ },
+ background() {
+ browser.idle.setDetectionInterval(99);
+ browser.idle.onStateChanged.addListener(newState => {
+ browser.test.assertEq(
+ "active",
+ newState,
+ "listener fired with the expected state"
+ );
+ browser.test.sendMessage("listenerFired");
+ });
+ },
+ });
+
+ idleService._reset();
+ await extension.startup();
+ assertPersistentListeners(extension, "idle", "onStateChanged", {
+ primed: false,
+ });
+ checkActivity({
+ expectedAdd: [99],
+ expectedRemove: [],
+ expectedFires: [],
+ });
+
+ idleService._reset();
+ await extension.terminateBackground();
+ assertPersistentListeners(extension, "idle", "onStateChanged", {
+ primed: true,
+ });
+ checkActivity({
+ expectedAdd: [99],
+ expectedRemove: [99],
+ expectedFires: [],
+ });
+
+ // Fire an idle notification to wake up the background.
+ idleService._fireObservers("active");
+ await extension.awaitMessage("listenerFired");
+ checkActivity({
+ expectedAdd: [99],
+ expectedRemove: [99],
+ expectedFires: ["active"],
+ });
+
+ // Verify the set idle time is used with the persisted listener.
+ idleService._reset();
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+
+ assertPersistentListeners(extension, "idle", "onStateChanged", {
+ primed: true,
+ });
+ checkActivity({
+ expectedAdd: [99], // 99 should have been persisted
+ expectedRemove: [99], // remove is from AOM shutdown
+ expectedFires: [],
+ });
+
+ // Fire an idle notification to wake up the background.
+ idleService._fireObservers("active");
+ await extension.awaitMessage("listenerFired");
+ checkActivity({
+ expectedAdd: [99],
+ expectedRemove: [99],
+ expectedFires: ["active"],
+ });
+
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js
new file mode 100644
index 0000000000..b4b00e7db4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js
@@ -0,0 +1,127 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged");
+
+add_task(async function setup() {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+async function runIncognitoTest(extensionData, privateBrowsingAllowed) {
+ let wrapper = ExtensionTestUtils.loadExtension(extensionData);
+ await wrapper.startup();
+ let { extension } = wrapper;
+
+ equal(
+ extension.permissions.has("internal:privateBrowsingAllowed"),
+ privateBrowsingAllowed,
+ "privateBrowsingAllowed in serialized extension"
+ );
+ equal(
+ extension.privateBrowsingAllowed,
+ privateBrowsingAllowed,
+ "privateBrowsingAllowed in extension"
+ );
+ equal(
+ extension.policy.privateBrowsingAllowed,
+ privateBrowsingAllowed,
+ "privateBrowsingAllowed on policy"
+ );
+
+ await wrapper.unload();
+}
+
+add_task(async function test_extension_incognito_spanning() {
+ await runIncognitoTest({}, false);
+});
+
+// Test that when we are restricted, we can override the restriction for tests.
+add_task(async function test_extension_incognito_override_spanning() {
+ let extensionData = {
+ incognitoOverride: "spanning",
+ };
+ await runIncognitoTest(extensionData, true);
+});
+
+// This tests that a privileged extension will always have private browsing.
+add_task(async function test_extension_incognito_privileged() {
+ let extensionData = {
+ isPrivileged: true,
+ };
+ await runIncognitoTest(extensionData, true);
+});
+
+add_task(async function test_extension_privileged_not_allowed() {
+ let addonId = "privileged_not_allowed@mochi.test";
+ let extensionData = {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: addonId } },
+ incognito: "not_allowed",
+ },
+ useAddonManager: "permanent",
+ isPrivileged: true,
+ };
+ let wrapper = ExtensionTestUtils.loadExtension(extensionData);
+ await wrapper.startup();
+ let policy = WebExtensionPolicy.getByID(addonId);
+ equal(
+ policy.extension.isPrivileged,
+ true,
+ "The test extension is privileged"
+ );
+ equal(
+ policy.privateBrowsingAllowed,
+ false,
+ "privateBrowsingAllowed is false"
+ );
+
+ await wrapper.unload();
+});
+
+// Test that we remove pb permission if an extension is updated to not_allowed.
+add_task(async function test_extension_upgrade_not_allowed() {
+ let addonId = "upgrade@mochi.test";
+ let extensionData = {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: addonId } },
+ incognito: "spanning",
+ },
+ useAddonManager: "permanent",
+ incognitoOverride: "spanning",
+ };
+ let wrapper = ExtensionTestUtils.loadExtension(extensionData);
+ await wrapper.startup();
+
+ let policy = WebExtensionPolicy.getByID(addonId);
+
+ equal(
+ policy.privateBrowsingAllowed,
+ true,
+ "privateBrowsingAllowed in extension"
+ );
+
+ extensionData.manifest.version = "2.0";
+ extensionData.manifest.incognito = "not_allowed";
+ await wrapper.upgrade(extensionData);
+
+ equal(wrapper.version, "2.0", "Expected extension version");
+ policy = WebExtensionPolicy.getByID(addonId);
+ equal(
+ policy.privateBrowsingAllowed,
+ false,
+ "privateBrowsingAllowed is false"
+ );
+
+ await wrapper.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js b/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js
new file mode 100644
index 0000000000..f355b1d43a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js
@@ -0,0 +1,147 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_task(async function test_indexedDB_principal() {
+ Services.prefs.setBoolPref("privacy.firstparty.isolate", true);
+
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {},
+ async background() {
+ browser.test.onMessage.addListener(async msg => {
+ if (msg == "create-storage") {
+ let request = window.indexedDB.open("TestDatabase");
+ request.onupgradeneeded = function (e) {
+ let db = e.target.result;
+ db.createObjectStore("TestStore");
+ };
+ request.onsuccess = function (e) {
+ let db = e.target.result;
+ let tx = db.transaction("TestStore", "readwrite");
+ let store = tx.objectStore("TestStore");
+ tx.oncomplete = () => browser.test.sendMessage("storage-created");
+ store.add("foo", "bar");
+ tx.onerror = function (e) {
+ browser.test.fail(`Failed with error ${tx.error.message}`);
+ // Don't wait for timeout
+ browser.test.sendMessage("storage-created");
+ };
+ };
+ request.onerror = function (e) {
+ browser.test.fail(`Failed with error ${request.error.message}`);
+ // Don't wait for timeout
+ browser.test.sendMessage("storage-created");
+ };
+ return;
+ }
+ if (msg == "check-storage") {
+ let dbRequest = window.indexedDB.open("TestDatabase");
+ dbRequest.onupgradeneeded = function () {
+ browser.test.fail("Database should exist");
+ browser.test.notifyFail("done");
+ };
+ dbRequest.onsuccess = function (e) {
+ let db = e.target.result;
+ let transaction = db.transaction("TestStore");
+ transaction.onerror = function (e) {
+ browser.test.fail(
+ `Failed with error ${transaction.error.message}`
+ );
+ browser.test.notifyFail("done");
+ };
+ let objectStore = transaction.objectStore("TestStore");
+ let request = objectStore.get("bar");
+ request.onsuccess = function (event) {
+ browser.test.assertEq(
+ request.result,
+ "foo",
+ "Got the expected data"
+ );
+ browser.test.notifyPass("done");
+ };
+ request.onerror = function (e) {
+ browser.test.fail(`Failed with error ${request.error.message}`);
+ browser.test.notifyFail("done");
+ };
+ };
+ dbRequest.onerror = function (e) {
+ browser.test.fail(`Failed with error ${dbRequest.error.message}`);
+ browser.test.notifyFail("done");
+ };
+ }
+ });
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage("create-storage");
+ await extension.awaitMessage("storage-created");
+
+ await extension.addon.disable();
+
+ Services.prefs.setBoolPref("privacy.firstparty.isolate", false);
+
+ await extension.addon.enable();
+ await extension.awaitStartup();
+
+ extension.sendMessage("check-storage");
+ await extension.awaitFinish("done");
+
+ await extension.unload();
+ Services.prefs.clearUserPref("privacy.firstparty.isolate");
+});
+
+add_task(async function test_indexedDB_ext_privateBrowsing() {
+ Services.prefs.setBoolPref("dom.indexedDB.privateBrowsing.enabled", true);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ incognitoOverride: "spanning",
+ files: {
+ "extpage.html": `<!DOCTYPE><script src="extpage.js"></script>`,
+ "extpage.js": async function () {
+ try {
+ const request = window.indexedDB.open("TestDatabasePrivateBrowsing");
+ await new Promise((resolve, reject) => {
+ request.onupgradeneeded = resolve;
+ request.onsuccess = resolve;
+ request.onerror = () => {
+ reject(request.error);
+ };
+ });
+ browser.test.notifyFail("indexedDB-expect-error-on-open");
+ } catch (err) {
+ browser.test.assertEq(
+ "InvalidStateError",
+ err.name,
+ "Expect an error raised on openeing indexedDB"
+ );
+ browser.test.notifyPass("indexedDB-expect-error-on-open");
+ }
+ },
+ },
+ });
+
+ await extension.startup();
+
+ const page = await ExtensionTestUtils.loadContentPage(
+ extension.extension.baseURI.resolve("extpage.html"),
+ { privateBrowsing: true }
+ );
+
+ await extension.awaitFinish("indexedDB-expect-error-on-open");
+
+ await page.close();
+ await extension.unload();
+
+ Services.prefs.clearUserPref("dom.indexedDB.privateBrowsing.enabled");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js b/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js
new file mode 100644
index 0000000000..dd90d9bbc8
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js
@@ -0,0 +1,150 @@
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+add_task(async function test_parent_to_child() {
+ async function background() {
+ const dbName = "broken-blob";
+ const dbStore = "blob-store";
+ const dbVersion = 1;
+ const blobContent = "Hello World!";
+
+ let db = await new Promise((resolve, reject) => {
+ let dbOpen = indexedDB.open(dbName, dbVersion);
+ dbOpen.onerror = event => {
+ browser.test.fail(`Error opening the DB: ${event.target.error}`);
+ browser.test.notifyFail("test-completed");
+ reject();
+ };
+ dbOpen.onsuccess = event => {
+ resolve(event.target.result);
+ };
+ dbOpen.onupgradeneeded = event => {
+ let dbobj = event.target.result;
+ dbobj.onerror = error => {
+ browser.test.fail(`Error updating the DB: ${error.target.error}`);
+ browser.test.notifyFail("test-completed");
+ reject();
+ };
+ dbobj.createObjectStore(dbStore);
+ };
+ });
+
+ async function save(blob) {
+ let txn = db.transaction([dbStore], "readwrite");
+ let store = txn.objectStore(dbStore);
+ let req = store.put(blob, "key");
+
+ return new Promise((resolve, reject) => {
+ req.onsuccess = () => {
+ resolve();
+ };
+ req.onerror = event => {
+ browser.test.fail(
+ `Error saving the blob into the DB: ${event.target.error}`
+ );
+ browser.test.notifyFail("test-completed");
+ reject();
+ };
+ });
+ }
+
+ async function load() {
+ let txn = db.transaction([dbStore], "readonly");
+ let store = txn.objectStore(dbStore);
+ let req = store.getAll();
+
+ return new Promise((resolve, reject) => {
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ })
+ .then(loadDetails => {
+ let blobs = [];
+ loadDetails.forEach(details => {
+ blobs.push(details);
+ });
+ return blobs[0];
+ })
+ .catch(err => {
+ browser.test.fail(
+ `Error loading the blob from the DB: ${err} :: ${err.stack}`
+ );
+ browser.test.notifyFail("test-completed");
+ });
+ }
+
+ browser.test.log("Blob creation");
+ await save(new Blob([blobContent]));
+ let blob = await load();
+
+ db.close();
+
+ browser.runtime.onMessage.addListener(([msg, what]) => {
+ browser.test.log("Message received from content: " + msg);
+ if (msg == "script-ready") {
+ return Promise.resolve({ blob });
+ }
+
+ if (msg == "script-value") {
+ browser.test.assertEq(blobContent, what, "blob content matches");
+ browser.test.notifyPass("test-completed");
+ return;
+ }
+
+ browser.test.fail(`Unexpected test message received: ${msg}`);
+ });
+
+ browser.test.sendMessage("bg-ready");
+ }
+
+ function contentScriptStart() {
+ browser.runtime.sendMessage(["script-ready"], response => {
+ let reader = new FileReader();
+ reader.addEventListener(
+ "load",
+ () => {
+ browser.runtime.sendMessage(["script-value", reader.result]);
+ },
+ { once: true }
+ );
+ reader.readAsText(response.blob);
+ });
+ }
+
+ let extensionData = {
+ background,
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script_start.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "content_script_start.js": contentScriptStart,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitMessage("bg-ready");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.awaitFinish("test-completed");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js
new file mode 100644
index 0000000000..aba25173d7
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js
@@ -0,0 +1,108 @@
+/* -*- 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_json_parser() {
+ const ID = "json@test.web.extension";
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ files: {
+ "manifest.json": String.raw`{
+ // This is a manifest.
+ "manifest_version": 2,
+ "browser_specific_settings": {"gecko": {"id": "${ID}"}},
+ "name": "This \" is // not a comment",
+ "version": "0.1\\" // , "description": "This is not a description"
+ }`,
+ },
+ });
+
+ let expectedManifest = {
+ manifest_version: 2,
+ browser_specific_settings: { gecko: { id: ID } },
+ name: 'This " is // not a comment',
+ version: "0.1\\",
+ };
+
+ let fileURI = Services.io.newFileURI(xpi);
+ let uri = NetUtil.newURI(`jar:${fileURI.spec}!/`);
+
+ let extension = new ExtensionData(uri, false);
+
+ await extension.parseManifest();
+
+ Assert.deepEqual(
+ extension.rawManifest,
+ expectedManifest,
+ "Manifest with correctly-filtered comments"
+ );
+
+ Services.obs.notifyObservers(xpi, "flush-cache-entry");
+});
+
+add_task(async function test_getExtensionVersionWithoutValidation() {
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ files: {
+ "manifest.json": String.raw`{
+ // This is valid JSON but not a valid manifest.
+ "version": ["This is not a valid version"]
+ }`,
+ },
+ });
+ let fileURI = Services.io.newFileURI(xpi);
+ let uri = NetUtil.newURI(`jar:${fileURI.spec}!/`);
+ let extension = new ExtensionData(uri, false);
+
+ let rawVersion = await extension.getExtensionVersionWithoutValidation();
+ Assert.deepEqual(
+ rawVersion,
+ ["This is not a valid version"],
+ "Got the raw value of the 'version' key from an (invalid) manifest file"
+ );
+
+ // The manifest lacks several required properties and manifest_version is
+ // invalid. The exact error here doesn't matter, as long as it shows that the
+ // manifest is invalid.
+ await Assert.rejects(
+ extension.parseManifest(),
+ /Unexpected params.manifestVersion value: undefined/,
+ "parseManifest() should reject an invalid manifest"
+ );
+
+ Services.obs.notifyObservers(xpi, "flush-cache-entry");
+});
+
+add_task(
+ {
+ pref_set: [
+ ["extensions.manifestV3.enabled", true],
+ ["extensions.webextensions.warnings-as-errors", false],
+ ],
+ },
+ async function test_applications_no_longer_valid_in_mv3() {
+ let id = "some@id";
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ files: {
+ "manifest.json": JSON.stringify({
+ manifest_version: 3,
+ name: "some name",
+ version: "0.1",
+ applications: { gecko: { id } },
+ }),
+ },
+ });
+
+ let fileURI = Services.io.newFileURI(xpi);
+ let uri = NetUtil.newURI(`jar:${fileURI.spec}!/`);
+
+ let extension = new ExtensionData(uri, false);
+
+ const { manifest } = await extension.parseManifest();
+ ok(
+ !Object.keys(manifest).includes("applications"),
+ "expected no applications key in manifest"
+ );
+
+ Services.obs.notifyObservers(xpi, "flush-cache-entry");
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js b/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js
new file mode 100644
index 0000000000..96cc124348
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js
@@ -0,0 +1,165 @@
+"use strict";
+
+const { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+
+add_setup(async function setup() {
+ // Add a test .ftl file
+ // (Note: other tests do this by patching L10nRegistry.load() but in
+ // this test L10nRegistry is also loaded in the extension process --
+ // just adding a new resource is easier than trying to patch
+ // L10nRegistry in all processes)
+ let dir = FileUtils.getDir("TmpD", ["l10ntest"]);
+ dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ await IOUtils.writeUTF8(
+ PathUtils.join(dir.path, "test.ftl"),
+ "key = value\n"
+ );
+
+ let target = Services.io.newFileURI(dir);
+ let resProto = Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler);
+
+ resProto.setSubstitution("l10ntest", target);
+
+ const source = new L10nFileSource(
+ "test",
+ "app",
+ Services.locale.requestedLocales,
+ "resource://l10ntest/"
+ );
+ L10nRegistry.getInstance().registerSources([source]);
+});
+
+// Test that privileged extensions can use fluent to get strings from
+// language packs (and that unprivileged extensions cannot)
+add_task(async function test_l10n_dom() {
+ const PAGE = `<!DOCTYPE html>
+ <html><head>
+ <meta charset="utf8">
+ <link rel="localization" href="test.ftl"/>
+ <script src="page.js"></script>
+ </head></html>`;
+
+ function SCRIPT() {
+ window.addEventListener(
+ "load",
+ async () => {
+ try {
+ await document.l10n.ready;
+ let result = await document.l10n.formatValue("key");
+ browser.test.sendMessage("result", { success: true, result });
+ } catch (err) {
+ browser.test.sendMessage("result", {
+ success: false,
+ msg: err.message,
+ });
+ }
+ },
+ { once: true }
+ );
+ }
+
+ async function runTest(isPrivileged) {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+ manifest: {
+ web_accessible_resources: ["page.html"],
+ },
+ isPrivileged,
+ files: {
+ "page.html": PAGE,
+ "page.js": SCRIPT,
+ },
+ });
+
+ await extension.startup();
+ let url = await extension.awaitMessage("ready");
+ let page = await ExtensionTestUtils.loadContentPage(url, { extension });
+ let results = await extension.awaitMessage("result");
+ await page.close();
+ await extension.unload();
+
+ return results;
+ }
+
+ // Everything should work for a privileged extension
+ let results = await runTest(true);
+ equal(results.success, true, "Translation succeeded in privileged extension");
+ equal(results.result, "value", "Translation got the right value");
+
+ // In an unprivileged extension, document.l10n shouldn't show up
+ results = await runTest(false);
+ equal(results.success, false, "Translation failed in unprivileged extension");
+ equal(
+ results.msg.endsWith("document.l10n is undefined"),
+ true,
+ "Translation failed due to missing document.l10n"
+ );
+});
+
+add_task(async function test_l10n_manifest() {
+ // Fluent can't be used to localize properties that the AddonManager
+ // reads (see comment inside ExtensionData.parseManifest for details)
+ // so test by localizing a property that only the extension framework
+ // cares about: page_action. This means we can only do this test from
+ // browser.
+ if (AppConstants.MOZ_BUILD_APP != "browser") {
+ return;
+ }
+
+ AddonTestUtils.initializeURLPreloader();
+
+ async function runTest({
+ isPrivileged = false,
+ temporarilyInstalled = false,
+ } = {}) {
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged,
+ temporarilyInstalled,
+ manifest: {
+ l10n_resources: ["test.ftl"],
+ page_action: {
+ default_title: "__MSG_key__",
+ },
+ },
+ });
+
+ if (temporarilyInstalled && !isPrivileged) {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await Assert.rejects(
+ extension.startup(),
+ /Using 'l10n_resources' requires a privileged add-on/,
+ "startup failed without privileged api access"
+ );
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ return;
+ }
+ await extension.startup();
+ let title = extension.extension.manifest.page_action.default_title;
+ await extension.unload();
+ return title;
+ }
+
+ let title = await runTest({ isPrivileged: true });
+ equal(
+ title,
+ "value",
+ "Manifest key localized with fluent in privileged extension"
+ );
+
+ title = await runTest();
+ equal(
+ title,
+ "__MSG_key__",
+ "Manifest key not localized in unprivileged extension"
+ );
+
+ title = await runTest({ temporarilyInstalled: true });
+ equal(title, undefined, "Startup fails with temporarilyInstalled extension");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js b/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js
new file mode 100644
index 0000000000..9adb549afe
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js
@@ -0,0 +1,50 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+function backgroundScript() {
+ let hasRun = localStorage.getItem("has-run");
+ let result;
+ if (!hasRun) {
+ localStorage.setItem("has-run", "yup");
+ localStorage.setItem("test-item", "item1");
+ result = "item1";
+ } else {
+ let data = localStorage.getItem("test-item");
+ if (data == "item1") {
+ localStorage.setItem("test-item", "item2");
+ result = "item2";
+ } else if (data == "item2") {
+ localStorage.removeItem("test-item");
+ result = "deleted";
+ } else if (!data) {
+ localStorage.clear();
+ result = "cleared";
+ }
+ }
+ browser.test.sendMessage("result", result);
+ browser.test.notifyPass("localStorage");
+}
+
+const ID = "test-webextension@mozilla.com";
+let extensionData = {
+ manifest: { browser_specific_settings: { gecko: { id: ID } } },
+ background: backgroundScript,
+};
+
+add_task(async function test_localStorage() {
+ const RESULTS = ["item1", "item2", "deleted", "cleared", "item1"];
+
+ for (let expected of RESULTS) {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ let actual = await extension.awaitMessage("result");
+
+ await extension.awaitFinish("localStorage");
+ await extension.unload();
+
+ equal(actual, expected, "got expected localStorage data");
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_management.js b/toolkit/components/extensions/test/xpcshell/test_ext_management.js
new file mode 100644
index 0000000000..8fb6b0d9a1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_management.js
@@ -0,0 +1,339 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+add_task(async function setup() {
+ Services.prefs.setBoolPref(
+ "extensions.webextOptionalPermissionPrompts",
+ false
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts");
+ });
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_management_permission() {
+ async function background() {
+ const permObj = { permissions: ["management"] };
+
+ let hasPerm = await browser.permissions.contains(permObj);
+ browser.test.assertTrue(!hasPerm, "does not have management permission");
+ browser.test.assertTrue(
+ !!browser.management,
+ "management namespace exists"
+ );
+ // These require permission
+ let requires_permission = [
+ "getAll",
+ "get",
+ "install",
+ "setEnabled",
+ "onDisabled",
+ "onEnabled",
+ "onInstalled",
+ "onUninstalled",
+ ];
+
+ async function testAvailable() {
+ // These are always available regardless of permission.
+ for (let fn of ["getSelf", "uninstallSelf"]) {
+ browser.test.assertTrue(
+ !!browser.management[fn],
+ `management.${fn} exists`
+ );
+ }
+
+ let hasPerm = await browser.permissions.contains(permObj);
+ for (let fn of requires_permission) {
+ browser.test.assertEq(
+ hasPerm,
+ !!browser.management[fn],
+ `management.${fn} does not exist`
+ );
+ }
+ }
+
+ await testAvailable();
+
+ browser.test.onMessage.addListener(async msg => {
+ browser.test.log("test with permission");
+
+ // get permission
+ await browser.permissions.request(permObj);
+ let hasPerm = await browser.permissions.contains(permObj);
+ browser.test.assertTrue(
+ hasPerm,
+ "management permission.request accepted"
+ );
+ await testAvailable();
+
+ browser.management.onInstalled.addListener(() => {
+ browser.test.fail("onInstalled listener invoked");
+ });
+
+ browser.test.log("test without permission");
+ // remove permission
+ await browser.permissions.remove(permObj);
+ hasPerm = await browser.permissions.contains(permObj);
+ browser.test.assertFalse(
+ hasPerm,
+ "management permission.request removed"
+ );
+ await testAvailable();
+
+ browser.test.sendMessage("done");
+ });
+
+ browser.test.sendMessage("started");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "management@test",
+ },
+ },
+ optional_permissions: ["management"],
+ },
+ background,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("started");
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request");
+ });
+ await extension.awaitMessage("done");
+
+ // Verify the onInstalled listener does not get used.
+ // The listener will make the test fail if fired.
+ let ext2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "on-installed@test",
+ },
+ },
+ optional_permissions: ["management"],
+ },
+ useAddonManager: "temporary",
+ });
+ await ext2.startup();
+ await ext2.unload();
+
+ await extension.unload();
+});
+
+add_task(async function test_management_getAll() {
+ const id1 = "get_all_test1@tests.mozilla.com";
+ const id2 = "get_all_test2@tests.mozilla.com";
+
+ function getManifest(id) {
+ return {
+ browser_specific_settings: {
+ gecko: {
+ id,
+ },
+ },
+ name: id,
+ version: "1.0",
+ short_name: id,
+ permissions: ["management"],
+ };
+ }
+
+ async function background() {
+ browser.test.onMessage.addListener(async (msg, id) => {
+ let addon = await browser.management.get(id);
+ browser.test.sendMessage("addon", addon);
+ });
+
+ let addons = await browser.management.getAll();
+ browser.test.assertEq(
+ 2,
+ addons.length,
+ "management.getAll returned correct number of add-ons."
+ );
+ browser.test.sendMessage("addons", addons);
+ }
+
+ let extension1 = ExtensionTestUtils.loadExtension({
+ manifest: getManifest(id1),
+ useAddonManager: "temporary",
+ });
+
+ let extension2 = ExtensionTestUtils.loadExtension({
+ manifest: getManifest(id2),
+ background,
+ useAddonManager: "temporary",
+ });
+
+ await extension1.startup();
+ await extension2.startup();
+
+ let addons = await extension2.awaitMessage("addons");
+ for (let id of [id1, id2]) {
+ let addon = addons.find(a => {
+ return a.id === id;
+ });
+ equal(
+ addon.name,
+ id,
+ `The extension with id ${id} was returned by getAll.`
+ );
+ equal(addon.shortName, id, "Additional extension metadata was correct");
+ }
+
+ extension2.sendMessage("getAddon", id1);
+ let addon = await extension2.awaitMessage("addon");
+ equal(addon.name, id1, `The extension with id ${id1} was returned by get.`);
+ equal(addon.shortName, id1, "Additional extension metadata was correct");
+
+ extension2.sendMessage("getAddon", id2);
+ addon = await extension2.awaitMessage("addon");
+ equal(addon.name, id2, `The extension with id ${id2} was returned by get.`);
+ equal(addon.shortName, id2, "Additional extension metadata was correct");
+
+ await extension2.unload();
+ await extension1.unload();
+});
+
+add_task(
+ { pref_set: [["extensions.eventPages.enabled", true]] },
+ async function test_management_event_page() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["management"],
+ background: { persistent: false },
+ },
+ background() {
+ browser.management.onInstalled.addListener(details => {
+ browser.test.sendMessage("onInstalled", details);
+ });
+ browser.management.onUninstalled.addListener(details => {
+ browser.test.sendMessage("onUninstalled", details);
+ });
+ browser.management.onEnabled.addListener(() => {
+ browser.test.sendMessage("onEnabled");
+ });
+ browser.management.onDisabled.addListener(() => {
+ browser.test.sendMessage("onDisabled");
+ });
+ },
+ });
+
+ await extension.startup();
+ let events = ["onInstalled", "onUninstalled", "onEnabled", "onDisabled"];
+ for (let event of events) {
+ assertPersistentListeners(extension, "management", event, {
+ primed: false,
+ });
+ }
+
+ await extension.terminateBackground();
+ for (let event of events) {
+ assertPersistentListeners(extension, "management", event, {
+ primed: true,
+ });
+ }
+
+ let testExt = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "test-ext@mochitest" } },
+ },
+ background() {},
+ });
+ await testExt.startup();
+
+ let details = await extension.awaitMessage("onInstalled");
+ equal(testExt.id, details.id, "got onInstalled event");
+
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+ await testExt.awaitStartup();
+
+ for (let event of events) {
+ assertPersistentListeners(extension, "management", event, {
+ primed: true,
+ });
+ }
+
+ // Test uninstalling an addon wakes up the watching extension.
+ let uninstalled = testExt.unload();
+
+ details = await extension.awaitMessage("onUninstalled");
+ equal(testExt.id, details.id, "got onUninstalled event");
+
+ await extension.unload();
+ await uninstalled;
+ }
+);
+
+// Sanity check that Addon listeners are removed on context close.
+add_task(
+ {
+ // __AddonManagerInternal__ is exposed for debug builds only.
+ skip_if: () => !AppConstants.DEBUG,
+ },
+ async function test_management_unregister_listener() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["management"],
+ },
+ files: {
+ "extpage.html": `<!DOCTYPE html><script src="extpage.js"></script>`,
+ "extpage.js": function () {
+ browser.management.onInstalled.addListener(() => {});
+ },
+ },
+ });
+
+ await extension.startup();
+
+ const page = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/extpage.html`
+ );
+
+ const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+ );
+ function assertManagementAPIAddonListener(expect) {
+ let found = false;
+ for (const addonListener of AddonManager.__AddonManagerInternal__
+ ?.addonListeners || []) {
+ if (
+ Object.getPrototypeOf(addonListener).constructor.name ===
+ "ManagementAddonListener"
+ ) {
+ found = true;
+ }
+ }
+ equal(
+ found,
+ expect,
+ `${
+ expect ? "Should" : "Should not"
+ } have found an AOM addonListener registered by the management API`
+ );
+ }
+
+ assertManagementAPIAddonListener(true);
+ await page.close();
+ assertManagementAPIAddonListener(false);
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js b/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js
new file mode 100644
index 0000000000..45c981811b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js
@@ -0,0 +1,146 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+const id = "uninstall_self_test@tests.mozilla.com";
+
+const manifest = {
+ browser_specific_settings: {
+ gecko: {
+ id,
+ },
+ },
+ name: "test extension name",
+ version: "1.0",
+};
+
+const waitForUninstalled = () =>
+ new Promise(resolve => {
+ const listener = {
+ onUninstalled: async addon => {
+ equal(addon.id, id, "The expected add-on has been uninstalled");
+ let checkedAddon = await AddonManager.getAddonByID(addon.id);
+ equal(checkedAddon, null, "Add-on no longer exists");
+ AddonManager.removeAddonListener(listener);
+ resolve();
+ },
+ };
+ AddonManager.addAddonListener(listener);
+ });
+
+let promptService = {
+ _response: null,
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ confirmEx: function (...args) {
+ this._confirmExArgs = args;
+ return this._response;
+ },
+};
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ let fakePromptService = MockRegistrar.register(
+ "@mozilla.org/prompter;1",
+ promptService
+ );
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(fakePromptService);
+ });
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_management_uninstall_no_prompt() {
+ function background() {
+ browser.test.onMessage.addListener(msg => {
+ browser.management.uninstallSelf();
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ background,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ let addon = await AddonManager.getAddonByID(id);
+ notEqual(addon, null, "Add-on is installed");
+ extension.sendMessage("uninstall");
+ await waitForUninstalled();
+ Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry");
+});
+
+add_task(async function test_management_uninstall_prompt_uninstall() {
+ promptService._response = 0;
+
+ function background() {
+ browser.test.onMessage.addListener(msg => {
+ browser.management.uninstallSelf({ showConfirmDialog: true });
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ background,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ let addon = await AddonManager.getAddonByID(id);
+ notEqual(addon, null, "Add-on is installed");
+ extension.sendMessage("uninstall");
+ await waitForUninstalled();
+
+ // Test localization strings
+ equal(promptService._confirmExArgs[1], `Uninstall ${manifest.name}`);
+ equal(
+ promptService._confirmExArgs[2],
+ `The extension “${manifest.name}” is requesting to be uninstalled. What would you like to do?`
+ );
+ equal(promptService._confirmExArgs[4], "Uninstall");
+ equal(promptService._confirmExArgs[5], "Keep Installed");
+ Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry");
+});
+
+add_task(async function test_management_uninstall_prompt_keep() {
+ promptService._response = 1;
+
+ function background() {
+ browser.test.onMessage.addListener(async msg => {
+ await browser.test.assertRejects(
+ browser.management.uninstallSelf({ showConfirmDialog: true }),
+ "User cancelled uninstall of extension",
+ "Expected rejection when user declines uninstall"
+ );
+
+ browser.test.sendMessage("uninstall-rejected");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ background,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ let addon = await AddonManager.getAddonByID(id);
+ notEqual(addon, null, "Add-on is installed");
+
+ extension.sendMessage("uninstall");
+ await extension.awaitMessage("uninstall-rejected");
+
+ addon = await AddonManager.getAddonByID(id);
+ notEqual(addon, null, "Add-on remains installed");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js
new file mode 100644
index 0000000000..4b9300fd40
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js
@@ -0,0 +1,460 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+async function testManifest(manifest, expectedError) {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ let normalized = await ExtensionTestUtils.normalizeManifest(manifest);
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ if (expectedError) {
+ ok(
+ expectedError.test(normalized.error),
+ `Should have an error for ${JSON.stringify(manifest)}, got ${
+ normalized.error
+ }`
+ );
+ } else {
+ ok(
+ !normalized.error,
+ `Should not have an error ${JSON.stringify(manifest)}, ${
+ normalized.error
+ }`
+ );
+ }
+ return normalized.errors;
+}
+
+async function testIconPaths(icon, manifest, expectedError) {
+ let normalized = await ExtensionTestUtils.normalizeManifest(manifest);
+
+ if (expectedError) {
+ ok(
+ expectedError.test(normalized.error),
+ `Should have an error for ${JSON.stringify(icon)}`
+ );
+ } else {
+ ok(!normalized.error, `Should not have an error ${JSON.stringify(icon)}`);
+ }
+}
+
+add_setup(async () => {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_manifest() {
+ let badpaths = ["", " ", "\t", "http://foo.com/icon.png"];
+ for (let path of badpaths) {
+ await testIconPaths(
+ path,
+ {
+ icons: path,
+ },
+ /Error processing icons/
+ );
+
+ await testIconPaths(
+ path,
+ {
+ icons: {
+ 16: path,
+ },
+ },
+ /Error processing icons/
+ );
+ }
+
+ let paths = [
+ "icon.png",
+ "/icon.png",
+ "./icon.png",
+ "path to an icon.png",
+ " icon.png",
+ ];
+ for (let path of paths) {
+ // manifest.icons is an object
+ await testIconPaths(
+ path,
+ {
+ icons: path,
+ },
+ /Error processing icons/
+ );
+
+ await testIconPaths(path, {
+ icons: {
+ 16: path,
+ },
+ });
+ }
+});
+
+add_task(async function test_manifest_warnings_on_unexpected_props() {
+ let extension = await ExtensionTestUtils.loadExtension({
+ manifest: {
+ background: {
+ scripts: ["bg.js"],
+ wrong_prop: true,
+ },
+ },
+ files: {
+ "bg.js": "",
+ },
+ });
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ // Retrieve the warning message collected by the Extension class
+ // packagingWarning method.
+ const { warnings } = extension.extension;
+ equal(warnings.length, 1, "Got the expected number of manifest warnings");
+
+ const expectedMessage =
+ "Reading manifest: Warning processing background.wrong_prop";
+ ok(
+ warnings[0].startsWith(expectedMessage),
+ "Got the expected warning message format"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_mv2_scripting_permission_always_enabled() {
+ let warnings = await testManifest({
+ manifest_version: 2,
+ permissions: ["scripting"],
+ });
+
+ Assert.deepEqual(warnings, [], "Got no warnings");
+});
+
+add_task(
+ {
+ pref_set: [["extensions.manifestV3.enabled", true]],
+ },
+ async function test_mv3_scripting_permission_always_enabled() {
+ let warnings = await testManifest({
+ manifest_version: 3,
+ permissions: ["scripting"],
+ });
+
+ Assert.deepEqual(warnings, [], "Got no warnings");
+ }
+);
+
+add_task(async function test_simpler_version_format() {
+ const TEST_CASES = [
+ // Valid cases
+ { version: "0", expectWarning: false },
+ { version: "0.0", expectWarning: false },
+ { version: "0.0.0", expectWarning: false },
+ { version: "0.0.0.0", expectWarning: false },
+ { version: "0.0.0.1", expectWarning: false },
+ { version: "0.0.0.999999999", expectWarning: false },
+ { version: "0.0.1.0", expectWarning: false },
+ { version: "0.0.999999999", expectWarning: false },
+ { version: "0.1.0.0", expectWarning: false },
+ { version: "0.999999999", expectWarning: false },
+ { version: "1", expectWarning: false },
+ { version: "1.0", expectWarning: false },
+ { version: "1.0.0", expectWarning: false },
+ { version: "1.0.0.0", expectWarning: false },
+ { version: "1.2.3.4", expectWarning: false },
+ { version: "999999999", expectWarning: false },
+ {
+ version: "999999999.999999999.999999999.999999999",
+ expectWarning: false,
+ },
+ // Invalid cases
+ { version: ".", expectWarning: true },
+ { version: ".999999999", expectWarning: true },
+ { version: "0.0.0.0.0", expectWarning: true },
+ { version: "0.0.0.00001", expectWarning: true },
+ { version: "0.0.0.0010", expectWarning: true },
+ { version: "0.0.00001", expectWarning: true },
+ { version: "0.0.001", expectWarning: true },
+ { version: "0.0.01.0", expectWarning: true },
+ { version: "0.01.0", expectWarning: true },
+ { version: "00001", expectWarning: true },
+ { version: "0001", expectWarning: true },
+ { version: "001", expectWarning: true },
+ { version: "01", expectWarning: true },
+ { version: "01.0", expectWarning: true },
+ { version: "099999", expectWarning: true },
+ { version: "0999999999", expectWarning: true },
+ { version: "1.00000", expectWarning: true },
+ { version: "1.1.-1", expectWarning: true },
+ { version: "1.1000000000", expectWarning: true },
+ { version: "1.1pre1aa", expectWarning: true },
+ { version: "1.2.1000000000", expectWarning: true },
+ { version: "1.2.3.4-a", expectWarning: true },
+ { version: "1.2.3.4.5", expectWarning: true },
+ { version: "1000000000", expectWarning: true },
+ { version: "1000000000.0.0.0", expectWarning: true },
+ { version: "999999999.", expectWarning: true },
+ ];
+
+ for (const { version, expectWarning } of TEST_CASES) {
+ const normalized = await ExtensionTestUtils.normalizeManifest({ version });
+
+ if (expectWarning) {
+ Assert.deepEqual(
+ normalized.errors,
+ [
+ `Warning processing version: version must be a version string ` +
+ `consisting of at most 4 integers of at most 9 digits without ` +
+ `leading zeros, and separated with dots`,
+ ],
+ `expected warning for version: ${version}`
+ );
+ } else {
+ Assert.deepEqual(
+ normalized.errors,
+ [],
+ `expected no warning for version: ${version}`
+ );
+ }
+ }
+});
+
+add_task(async function test_applications() {
+ const id = "some@id";
+ const updateURL = "https://example.com/updates/";
+
+ let extension = await ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 2,
+ applications: {
+ gecko: { id, update_url: updateURL },
+ },
+ },
+ useAddonManager: "temporary",
+ });
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ Assert.deepEqual(extension.extension.warnings, [], "expected no warnings");
+
+ const addon = await AddonManager.getAddonByID(extension.id);
+ ok(addon, "got an add-on");
+ equal(addon.id, id, "got expected ID");
+ equal(addon.updateURL, updateURL, "got expected update URL");
+
+ await extension.unload();
+});
+
+add_task(
+ {
+ pref_set: [["extensions.manifestV3.enabled", true]],
+ },
+ async function test_applications_key_mv3() {
+ let warnings = await testManifest({
+ manifest_version: 3,
+ applications: {},
+ });
+
+ Assert.deepEqual(
+ warnings,
+ [`Property "applications" is unsupported in Manifest Version 3`],
+ `Manifest v3 with "applications" key logs an error.`
+ );
+ }
+);
+
+add_task(async function test_bss_gecko_android() {
+ const addonId = "some@id";
+ const isAndroid = AppConstants.platform == "android";
+
+ const TEST_CASES = [
+ {
+ title: "gecko_android overrides gecko",
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "2",
+ strict_max_version: "2",
+ },
+ gecko_android: {
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ expectedError: isAndroid
+ ? `Add-on ${addonId} is not compatible with application version. add-on minVersion: 1. add-on maxVersion: 1.`
+ : `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 2.`,
+ },
+ {
+ title:
+ "strict_min_version in gecko_android overrides gecko.strict_min_version",
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "2",
+ strict_max_version: "3",
+ },
+ gecko_android: {
+ strict_min_version: "3",
+ },
+ },
+ expectedError: isAndroid
+ ? `Add-on ${addonId} is not compatible with application version. add-on minVersion: 3. add-on maxVersion: 3.`
+ : `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 3.`,
+ },
+ {
+ title:
+ "strict_max_version in gecko_android overrides gecko.strict_max_version",
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "2",
+ strict_max_version: "2",
+ },
+ gecko_android: {
+ strict_max_version: "3",
+ },
+ },
+ expectedError: isAndroid
+ ? `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 3.`
+ : `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 2.`,
+ },
+ {
+ title: "no gecko_android",
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "2",
+ strict_max_version: "2",
+ },
+ },
+ expectedError: `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 2.`,
+ },
+ {
+ title: "empty gecko_android",
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "2",
+ strict_max_version: "2",
+ },
+ gecko_android: {},
+ },
+ expectedError: `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 2.`,
+ },
+ {
+ title: "empty strict min/max versions in gecko_android",
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "2",
+ strict_max_version: "2",
+ },
+ gecko_android: {
+ strict_min_version: "",
+ strict_max_version: "",
+ },
+ },
+ expectedError: `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 2.`,
+ },
+ {
+ title: "unsupported prop in gecko_android",
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "2",
+ strict_max_version: "2",
+ },
+ gecko_android: {
+ aPropThatIsNotSupported: "aPropThatIsNotSupported",
+ },
+ },
+ expectedError: `Add-on ${addonId} is not compatible with application version. add-on minVersion: 2. add-on maxVersion: 2.`,
+ },
+ {
+ title: "only strict min/max version in gecko_android",
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ },
+ gecko_android: {
+ strict_min_version: "3",
+ strict_max_version: "4",
+ },
+ },
+ expectedError: isAndroid
+ ? `Add-on ${addonId} is not compatible with application version. add-on minVersion: 3. add-on maxVersion: 4.`
+ : null,
+ },
+ {
+ title: "only strict_min_version in gecko_android",
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ },
+ gecko_android: {
+ // The app version is set to `42` at the top of the file.
+ strict_min_version: "100",
+ },
+ },
+ expectedError: isAndroid
+ ? `Add-on ${addonId} is not compatible with application version. add-on minVersion: 100.`
+ : null,
+ },
+ ];
+
+ for (const {
+ title,
+ browser_specific_settings,
+ expectedError,
+ } of TEST_CASES) {
+ info(`verifying: ${title}`);
+
+ // This task is mainly about verifying `bss.gecko_android` and some test
+ // cases require a "valid" compatibility range by default, which would
+ // break the assumption below (that the install is going to fail). This is
+ // why we skip null errors, but only on non-Android builds.
+ if (expectedError === null) {
+ notEqual(
+ AppConstants.platform,
+ "android",
+ `${title} - expected no error on a non-Android build`
+ );
+ continue;
+ }
+
+ const manifest = {
+ manifest_version: 2,
+ version: "1.0",
+ browser_specific_settings,
+ };
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "temporary",
+ });
+
+ await Assert.rejects(
+ extension.startup(),
+ new RegExp(expectedError),
+ `${title} - expected error: ${expectedError}`
+ );
+
+ const addon = await AddonManager.getAddonByID(addonId);
+ equal(addon, null, "add-on is not installed");
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js
new file mode 100644
index 0000000000..a6e3f91a6b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js
@@ -0,0 +1,114 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+add_task(async function test_manifest_csp() {
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ content_security_policy: "script-src 'self'; object-src 'none'",
+ });
+
+ equal(normalized.error, undefined, "Should not have an error");
+ equal(normalized.errors.length, 0, "Should not have warnings");
+ equal(
+ normalized.value.content_security_policy,
+ "script-src 'self'; object-src 'none'",
+ "Should have the expected policy string"
+ );
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ content_security_policy: "object-src 'none'",
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ equal(normalized.error, undefined, "Should not have an error");
+
+ Assert.deepEqual(
+ normalized.errors,
+ [
+ "Error processing content_security_policy: Policy is missing a required ‘script-src’ directive",
+ ],
+ "Should have the expected warning"
+ );
+
+ equal(
+ normalized.value.content_security_policy,
+ null,
+ "Invalid policy string should be omitted"
+ );
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ manifest_version: 2,
+ content_security_policy: {
+ extension_pages: "script-src 'self'; object-src 'none'",
+ },
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ Assert.deepEqual(
+ normalized.errors,
+ [
+ `Error processing content_security_policy: Expected string instead of {"extension_pages":"script-src 'self'; object-src 'none'"}`,
+ ],
+ "Should have the expected warning"
+ );
+});
+
+add_task(async function test_manifest_csp_v3() {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ manifest_version: 3,
+ content_security_policy: "script-src 'self'; object-src 'none'",
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ Assert.deepEqual(
+ normalized.errors,
+ [
+ `Error processing content_security_policy: Expected object instead of "script-src 'self'; object-src 'none'"`,
+ ],
+ "Should have the expected warning"
+ );
+
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: "script-src 'self' 'unsafe-eval'; object-src 'none'",
+ },
+ });
+
+ Assert.deepEqual(
+ normalized.errors,
+ [
+ "Error processing content_security_policy.extension_pages: ‘script-src’ directive contains a forbidden 'unsafe-eval' keyword",
+ ],
+ "Should have the expected warning"
+ );
+ equal(
+ normalized.value.content_security_policy.extension_pages,
+ null,
+ "Should have the expected policy string"
+ );
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: "object-src 'none'",
+ },
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ equal(normalized.error, undefined, "Should not have an error");
+ equal(normalized.errors.length, 1, "Should have warnings");
+ Assert.deepEqual(
+ normalized.errors,
+ [
+ "Error processing content_security_policy.extension_pages: Policy is missing a required ‘script-src’ directive",
+ ],
+ "Should have the expected warning for extension_pages CSP"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js
new file mode 100644
index 0000000000..4330e1b681
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js
@@ -0,0 +1,45 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_manifest_incognito() {
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ incognito: "spanning",
+ });
+
+ equal(normalized.error, undefined, "Should not have an error");
+ equal(normalized.errors.length, 0, "Should not have warnings");
+ equal(
+ normalized.value.incognito,
+ "spanning",
+ "Should have the expected incognito string"
+ );
+
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ incognito: "not_allowed",
+ });
+
+ equal(normalized.error, undefined, "Should not have an error");
+ equal(normalized.errors.length, 0, "Should not have warnings");
+ equal(
+ normalized.value.incognito,
+ "not_allowed",
+ "Should have the expected incognito string"
+ );
+
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ incognito: "split",
+ });
+
+ equal(
+ normalized.error,
+ 'Error processing incognito: Invalid enumeration value "split"',
+ "Should have an error"
+ );
+ Assert.deepEqual(normalized.errors, [], "Should not have a warning");
+ equal(
+ normalized.value,
+ undefined,
+ "Invalid incognito string should be undefined"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js
new file mode 100644
index 0000000000..39119513fb
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js
@@ -0,0 +1,12 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_manifest_minimum_chrome_version() {
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ minimum_chrome_version: "42",
+ });
+
+ equal(normalized.error, undefined, "Should not have an error");
+ equal(normalized.errors.length, 0, "Should not have warnings");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js
new file mode 100644
index 0000000000..943e8b7270
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js
@@ -0,0 +1,12 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_manifest_minimum_opera_version() {
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ minimum_opera_version: "48",
+ });
+
+ equal(normalized.error, undefined, "Should not have an error");
+ equal(normalized.errors.length, 0, "Should not have warnings");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js
new file mode 100644
index 0000000000..8cd44f06dc
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js
@@ -0,0 +1,35 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+async function test_theme_property(property) {
+ let normalized = await ExtensionTestUtils.normalizeManifest(
+ {
+ theme: {
+ [property]: {},
+ },
+ },
+ "manifest.ThemeManifest"
+ );
+
+ if (property === "unrecognized_key") {
+ const expectedWarning = `Warning processing theme.${property}`;
+ ok(
+ normalized.errors[0].includes(expectedWarning),
+ `The manifest warning ${JSON.stringify(
+ normalized.errors[0]
+ )} must contain ${JSON.stringify(expectedWarning)}`
+ );
+ } else {
+ equal(normalized.errors.length, 0, "Should have a warning");
+ }
+ equal(normalized.error, undefined, "Should not have an error");
+}
+
+add_task(async function test_manifest_themes() {
+ await test_theme_property("images");
+ await test_theme_property("colors");
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await test_theme_property("unrecognized_key");
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js
new file mode 100644
index 0000000000..120bebb431
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js
@@ -0,0 +1,277 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+let { promiseRestartManager, promiseShutdownManager, promiseStartupManager } =
+ AddonTestUtils;
+
+const PAGE_HTML = `<!DOCTYPE html><meta charset="utf-8"><script src="script.js"></script>`;
+
+function trackEvents(wrapper) {
+ let events = new Map();
+ for (let event of ["background-script-event", "start-background-script"]) {
+ events.set(event, false);
+ wrapper.extension.once(event, () => events.set(event, true));
+ }
+ return events;
+}
+
+async function test(what, background, script) {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/*"],
+ js: ["script.js"],
+ },
+ ],
+ },
+
+ files: {
+ "page.html": PAGE_HTML,
+ "script.js": script,
+ },
+
+ background,
+ });
+
+ info(`Set up ${what} listener`);
+ await extension.startup();
+ await extension.awaitMessage("bg-ran");
+
+ info(`Test wakeup for ${what} from an extension page`);
+ await promiseRestartManager({ earlyStartup: false });
+ await extension.awaitStartup();
+
+ function awaitBgEvent() {
+ return new Promise(resolve =>
+ extension.extension.once("background-script-event", resolve)
+ );
+ }
+
+ let events = trackEvents(extension);
+
+ let url = extension.extension.baseURI.resolve("page.html");
+
+ let [, page] = await Promise.all([
+ awaitBgEvent(),
+ ExtensionTestUtils.loadContentPage(url, { extension }),
+ ]);
+
+ equal(
+ events.get("background-script-event"),
+ true,
+ "Should have gotten a background page event"
+ );
+ equal(
+ events.get("start-background-script"),
+ false,
+ "Background page should not be started"
+ );
+
+ equal(extension.messageQueue.size, 0, "Have not yet received bg-ran message");
+
+ let promise = extension.awaitMessage("bg-ran");
+ AddonTestUtils.notifyEarlyStartup();
+ await promise;
+
+ equal(
+ events.get("start-background-script"),
+ true,
+ "Should have gotten start-background-script event"
+ );
+
+ await extension.awaitFinish("messaging-test");
+ ok(true, "Background page loaded and received message from extension page");
+
+ await page.close();
+
+ info(`Test wakeup for ${what} from a content script`);
+ await promiseRestartManager({ earlyStartup: false });
+ await extension.awaitStartup();
+
+ events = trackEvents(extension);
+
+ [, page] = await Promise.all([
+ awaitBgEvent(),
+ ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ ),
+ ]);
+
+ equal(
+ events.get("background-script-event"),
+ true,
+ "Should have gotten a background script event"
+ );
+ equal(
+ events.get("start-background-script"),
+ false,
+ "Background script should not be started"
+ );
+
+ equal(extension.messageQueue.size, 0, "Have not yet received bg-ran message");
+
+ promise = extension.awaitMessage("bg-ran");
+ AddonTestUtils.notifyEarlyStartup();
+ await promise;
+
+ equal(
+ events.get("start-background-script"),
+ true,
+ "Should have gotten start-background-script event"
+ );
+
+ await extension.awaitFinish("messaging-test");
+ ok(true, "Background page loaded and received message from content script");
+
+ await page.close();
+ await extension.unload();
+
+ await promiseShutdownManager();
+}
+
+add_task(function test_onMessage() {
+ function script() {
+ browser.runtime.sendMessage("ping").then(reply => {
+ browser.test.assertEq(
+ reply,
+ "pong",
+ "Extension page received pong reply"
+ );
+ browser.test.notifyPass("messaging-test");
+ });
+ }
+
+ async function background() {
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ browser.test.assertEq(
+ msg,
+ "ping",
+ "Background page received ping message"
+ );
+ return Promise.resolve("pong");
+ });
+
+ // addListener() returns right away but make a round trip to the
+ // main process to ensure the persistent onMessage listener is recorded.
+ await browser.runtime.getBrowserInfo();
+ browser.test.sendMessage("bg-ran");
+ }
+
+ return test("onMessage", background, script);
+});
+
+add_task(function test_onConnect() {
+ function script() {
+ let port = browser.runtime.connect();
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, "pong", "Extension page received pong reply");
+ browser.test.notifyPass("messaging-test");
+ });
+ port.postMessage("ping");
+ }
+
+ async function background() {
+ browser.runtime.onConnect.addListener(port => {
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(
+ msg,
+ "ping",
+ "Background page received ping message"
+ );
+ port.postMessage("pong");
+ });
+ });
+
+ // addListener() returns right away but make a round trip to the
+ // main process to ensure the persistent onMessage listener is recorded.
+ await browser.runtime.getBrowserInfo();
+ browser.test.sendMessage("bg-ran");
+ }
+
+ return test("onConnect", background, script);
+});
+
+// Test that messaging works if the background page is started before
+// any messages are exchanged. (See bug 1467136 for an example of how
+// this broke at one point).
+add_task(async function test_other_startup() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+
+ async background() {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.notifyPass("startup");
+ });
+
+ // addListener() returns right away but make a round trip to the
+ // main process to ensure the persistent onMessage listener is recorded.
+ await browser.runtime.getBrowserInfo();
+ browser.test.sendMessage("bg-ran");
+ },
+
+ files: {
+ "page.html": PAGE_HTML,
+ "script.js"() {
+ browser.runtime.sendMessage("ping");
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bg-ran");
+
+ await promiseRestartManager({ lateStartup: false });
+ await extension.awaitStartup();
+ let events = trackEvents(extension);
+
+ equal(
+ events.get("background-script-event"),
+ false,
+ "Should not have gotten a background page event"
+ );
+ equal(
+ events.get("start-background-script"),
+ false,
+ "Background page should not be started"
+ );
+
+ // Start the background page. No message have been sent at this point.
+ await AddonTestUtils.notifyLateStartup();
+ equal(
+ events.get("start-background-script"),
+ true,
+ "Background page should be started"
+ );
+
+ await extension.awaitMessage("bg-ran");
+
+ // Now that the background page is fully started, load a new page that
+ // sends a message to the background page.
+ let url = extension.extension.baseURI.resolve("page.html");
+ let page = await ExtensionTestUtils.loadContentPage(url, { extension });
+
+ await extension.awaitFinish("startup");
+
+ await page.close();
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js
new file mode 100644
index 0000000000..cb08a70151
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js
@@ -0,0 +1,1111 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* globals chrome */
+
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes";
+const PREF_MAX_WRITE =
+ "webextensions.native-messaging.max-output-message-bytes";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+const ECHO_BODY = String.raw`
+ import struct
+ import sys
+
+ stdin = getattr(sys.stdin, 'buffer', sys.stdin)
+ stdout = getattr(sys.stdout, 'buffer', sys.stdout)
+
+ while True:
+ rawlen = stdin.read(4)
+ if len(rawlen) == 0:
+ sys.exit(0)
+ msglen = struct.unpack('@I', rawlen)[0]
+ msg = stdin.read(msglen)
+
+ stdout.write(struct.pack('@I', msglen))
+ stdout.write(msg)
+`;
+
+const INFO_BODY = String.raw`
+ import json
+ import os
+ import struct
+ import sys
+
+ msg = json.dumps({"args": sys.argv, "cwd": os.getcwd()})
+ if sys.version_info >= (3,):
+ sys.stdout.buffer.write(struct.pack('@I', len(msg)))
+ else:
+ sys.stdout.write(struct.pack('@I', len(msg)))
+ sys.stdout.write(msg)
+ sys.exit(0)
+`;
+
+const DELAYED_ECHO_BODY = String.raw`
+ import atexit
+ import json
+ import os
+ import struct
+ import sys
+ import time
+
+ stdin = getattr(sys.stdin, 'buffer', sys.stdin)
+ stdout = getattr(sys.stdout, 'buffer', sys.stdout)
+ pid = os.getpid()
+
+ sys.stderr.write("nativeapp with pid %d is running\n" % pid)
+
+ def onexit():
+ sys.stderr.write("nativeapp with pid %d is exiting\n" % pid)
+
+ atexit.register(onexit)
+
+ while True:
+ rawlen = stdin.read(4)
+ if len(rawlen) == 0:
+ sys.exit(0)
+ msglen = struct.unpack('@I', rawlen)[0]
+ msg = stdin.read(msglen)
+
+ sys.stderr.write(
+ "nativeapp with pid %d delaying echoing message '%s'\n" %
+ (pid, str(msg, 'utf-8'))
+ )
+
+ time.sleep(5)
+ stdout.write(struct.pack('@I', msglen))
+ stdout.write(msg)
+
+ sys.stderr.write(
+ "nativeapp with pid %d replied to message '%s'\n" %
+ (pid, str(msg, 'utf-8'))
+ )
+`;
+
+const STDERR_LINES = ["hello stderr", "this should be a separate line"];
+let STDERR_MSG = STDERR_LINES.join("\\n");
+
+const STDERR_BODY = String.raw`
+ import sys
+ sys.stderr.write("${STDERR_MSG}")
+`;
+
+const PLATFORM_PATH_SEP = AppConstants.platform == "win" ? "\\" : "/";
+
+let SCRIPTS = [
+ {
+ name: "echo",
+ description: "a native app that echoes back messages it receives",
+ script: ECHO_BODY.replace(/^ {2}/gm, ""),
+ },
+ {
+ name: "relative.echo",
+ description: "a native app that echoes; relative path instead of absolute",
+ script: ECHO_BODY.replace(/^ {2}/gm, ""),
+ _hookModifyManifest(manifest) {
+ manifest.path = PathUtils.filename(manifest.path);
+ },
+ },
+ {
+ name: "relative_dotdot.echo",
+ description: "a native app that echos; relative path with dot dot",
+ script: ECHO_BODY.replace(/^ {2}/gm, ""),
+ _hookModifyManifest(manifest) {
+ // Set to ..\NativeMessagingHosts\relative_dotdot.bat (Windows)
+ manifest.path = [
+ "..",
+ ...manifest.path.split(PLATFORM_PATH_SEP).slice(-2),
+ ].join(PLATFORM_PATH_SEP);
+ },
+ },
+ {
+ name: "renamed.echo",
+ description: "invalid manifest due to name mismatch",
+ script: ECHO_BODY.replace(/^ {2}/gm, ""),
+ _hookModifyManifest(manifest) {
+ manifest.name = "renamed_name_mismatch";
+ },
+ },
+ {
+ name: "nonstdio.echo",
+ description: "invalid manifest due to non-stdio type",
+ script: ECHO_BODY.replace(/^ {2}/gm, ""),
+ _hookModifyManifest(manifest) {
+ // schema only permits "stdio" or "pkcs11". Change from "stdio":
+ manifest.type = "pkcs11";
+ },
+ },
+ {
+ name: "forwardslash.echo",
+ description: "a native app that echos; with forward slash in path",
+ script: ECHO_BODY.replace(/^ {2}/gm, ""),
+ _hookModifyManifest(manifest) {
+ // On Linux/macOS, this doesn't change anything.
+ // On Windows, this turns C:\Program Files\... in C:/Program Files/...
+ manifest.path = manifest.path.replaceAll("\\", "/");
+ },
+ },
+ {
+ name: "dot.echo",
+ description: "a native app that echos; with dot slash in path",
+ script: ECHO_BODY.replace(/^ {2}/gm, ""),
+ _hookModifyManifest(manifest) {
+ // Replace / with /./ (or \ with \.\ on Windows).
+ manifest.path = manifest.path.replaceAll(
+ PLATFORM_PATH_SEP,
+ PLATFORM_PATH_SEP + "." + PLATFORM_PATH_SEP
+ );
+ },
+ },
+ {
+ name: "dotdot.echo",
+ description: "a native app that echos; with dot dot slash in path",
+ script: ECHO_BODY.replace(/^ {2}/gm, ""),
+ _hookModifyManifest(manifest) {
+ // The binary is in a directory called "TYPE_SLUG". Turn
+ // /TYPE_SLUG/ in /TYPE_SLUG/../TYPE_SLUG/ to have equivalent directories
+ // that ought to be considered a valid absolute path.
+ const dirWithSlashes = PLATFORM_PATH_SEP + TYPE_SLUG + PLATFORM_PATH_SEP;
+ manifest.path = manifest.path.replace(
+ dirWithSlashes,
+ dirWithSlashes + ".." + dirWithSlashes
+ );
+ },
+ },
+ {
+ name: "delayedecho",
+ description:
+ "a native app that echo messages received with a small artificial delay",
+ script: DELAYED_ECHO_BODY.replace(/^ {2}/gm, ""),
+ },
+ {
+ name: "info",
+ description: "a native app that gives some info about how it was started",
+ script: INFO_BODY.replace(/^ {2}/gm, ""),
+ },
+ {
+ name: "stderr",
+ description: "a native app that writes to stderr and then exits",
+ script: STDERR_BODY.replace(/^ {2}/gm, ""),
+ },
+];
+
+if (AppConstants.platform == "win") {
+ SCRIPTS.push({
+ name: "echocmd",
+ description: "echo but using a .cmd file",
+ scriptExtension: "cmd",
+ script: ECHO_BODY.replace(/^ {2}/gm, ""),
+ });
+}
+
+add_setup(async function setup() {
+ optionalPermissionsPromptHandler.init();
+ optionalPermissionsPromptHandler.acceptPrompt = true;
+ await AddonTestUtils.promiseStartupManager();
+
+ await setupHosts(SCRIPTS);
+});
+
+// Test the basic operation of native messaging with a simple
+// script that echoes back whatever message is sent to it.
+add_task(async function test_happy_path() {
+ async function background() {
+ let port;
+ browser.test.onMessage.addListener(async (what, payload) => {
+ if (what == "request") {
+ await browser.permissions.request({ permissions: ["nativeMessaging"] });
+ // connectNative requires permission
+ port = browser.runtime.connectNative("echo");
+ port.onMessage.addListener(msg => {
+ browser.test.sendMessage("message", msg);
+ });
+ browser.test.sendMessage("ready");
+ } else if (what == "send") {
+ if (payload._json) {
+ let json = payload._json;
+ payload.toJSON = () => json;
+ delete payload._json;
+ }
+ port.postMessage(payload);
+ }
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ optional_permissions: ["nativeMessaging"],
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request");
+ await extension.awaitMessage("ready");
+ });
+ const tests = [
+ {
+ data: "this is a string",
+ what: "simple string",
+ },
+ {
+ data: "Это юникода",
+ what: "unicode string",
+ },
+ {
+ data: { test: "hello" },
+ what: "simple object",
+ },
+ {
+ data: {
+ what: "An object with a few properties",
+ number: 123,
+ bool: true,
+ nested: { what: "another object" },
+ },
+ what: "object with several properties",
+ },
+
+ {
+ data: {
+ ignoreme: true,
+ _json: { data: "i have a tojson method" },
+ },
+ expected: { data: "i have a tojson method" },
+ what: "object with toJSON() method",
+ },
+ ];
+ for (let test of tests) {
+ extension.sendMessage("send", test.data);
+ let response = await extension.awaitMessage("message");
+ let expected = test.expected || test.data;
+ deepEqual(response, expected, `Echoed a message of type ${test.what}`);
+ }
+
+ let procCount = await getSubprocessCount();
+ equal(procCount, 1, "subprocess is still running");
+ let exitPromise = waitForSubprocessExit();
+ await extension.unload();
+ await exitPromise;
+});
+
+// Just test that the given app (which should be the echo script above)
+// can be started. Used to test corner cases in how the native application
+// is located/launched.
+async function simpleTest(app) {
+ function background(appname) {
+ let port = browser.runtime.connectNative(appname);
+ let MSG = "test";
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(MSG, msg, "Got expected message back");
+ browser.test.sendMessage("done");
+ });
+ port.postMessage(MSG);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${background})(${JSON.stringify(app)});`,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+
+ let procCount = await getSubprocessCount();
+ equal(procCount, 1, "subprocess is still running");
+ let exitPromise = waitForSubprocessExit();
+ await extension.unload();
+ await exitPromise;
+}
+
+async function testBrokenApp({
+ extensionId = ID,
+ appname,
+ expectedError,
+ expectedConsoleMessages,
+}) {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.onMessage.addListener(async (appname, expectedError) => {
+ await browser.test.assertRejects(
+ browser.runtime.sendNativeMessage(appname, "dummymsg"),
+ expectedError,
+ "Expected sendNativeMessage error"
+ );
+ browser.test.sendMessage("done");
+ });
+ },
+ manifest: {
+ browser_specific_settings: { gecko: { id: extensionId } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ let { messages } = await promiseConsoleOutput(async () => {
+ extension.sendMessage(appname, expectedError);
+ await extension.awaitMessage("done");
+ });
+ await extension.unload();
+
+ let procCount = await getSubprocessCount();
+ equal(procCount, 0, "No child process was started");
+
+ // Because we're using forbidUnexpected:true below, we have to account for
+ // all logged messages. RemoteSettings may try (and fail) to load remote
+ // settings - ignore the "NetworkError: Network request failed" error.
+ // To avoid having to update this filter all the time, select the specific
+ // modules relevant to native messaging from where we expect errors.
+ messages = messages.filter(m => {
+ return /NativeMessaging|NativeManifests|Subprocess/.test(m.message);
+ });
+
+ // On Linux/macOS, the setupHosts helper registers the same manifest file in
+ // multiple locations, which can result in the same error being printed
+ // multiple times. We de-duplicate that here.
+ let deduplicatedMessages = messages.filter(
+ (msg, i) => i === messages.findIndex(m => m.message === msg.message)
+ );
+
+ // Now check that all the log messages exist, in the expected order too.
+ AddonTestUtils.checkMessages(
+ deduplicatedMessages,
+ {
+ expected: expectedConsoleMessages.map(message => ({ message })),
+ forbidUnexpected: true,
+ },
+ "Expected messages in the console"
+ );
+}
+
+if (AppConstants.platform == "win") {
+ // "relative.echo" has a relative path in the host manifest.
+ add_task(function test_relative_path() {
+ // Note: relative paths only supported on Windows.
+ // For non-Windows, see test_relative_path_unsupported instead.
+ return simpleTest("relative.echo");
+ });
+
+ add_task(function test_relative_dotdot_path() {
+ // Note: relative paths only supported on Windows.
+ // For non-Windows, see test_relative_dotdot_path_unsupported instead.
+ return simpleTest("relative_dotdot.echo");
+ });
+
+ // "echocmd" uses a .cmd file instead of a .bat file
+ add_task(function test_cmd_file() {
+ return simpleTest("echocmd");
+ });
+} else {
+ // On non-Windows, relative paths are not supported.
+ add_task(function test_relative_path_unsupported() {
+ return testBrokenApp({
+ appname: "relative.echo",
+ expectedError: "An unexpected error occurred",
+ expectedConsoleMessages: [
+ /NativeApp requires absolute path to command on this platform/,
+ ],
+ });
+ });
+ add_task(function test_relative_dotdot_path_unsupported() {
+ return testBrokenApp({
+ appname: "relative_dotdot.echo",
+ expectedError: "An unexpected error occurred",
+ expectedConsoleMessages: [
+ /NativeApp requires absolute path to command on this platform/,
+ ],
+ });
+ });
+}
+
+add_task(async function test_absolute_path_dot_one() {
+ return simpleTest("dot.echo");
+});
+
+add_task(async function test_absolute_path_dotdot() {
+ return simpleTest("dotdot.echo");
+});
+
+add_task(async function test_error_name_mismatch() {
+ await testBrokenApp({
+ appname: "renamed.echo",
+ expectedError: "No such native application renamed.echo",
+ expectedConsoleMessages: [
+ /Native manifest .+ has name property renamed_name_mismatch \(expected renamed\.echo\)/,
+ /No such native application renamed\.echo/,
+ ],
+ });
+});
+
+add_task(async function test_invalid_manifest_type_not_stdio() {
+ await testBrokenApp({
+ appname: "nonstdio.echo",
+ expectedError: "No such native application nonstdio.echo",
+ expectedConsoleMessages: [
+ /Native manifest .+ has type property pkcs11 \(expected stdio\)/,
+ /No such native application nonstdio\.echo/,
+ ],
+ });
+});
+
+add_task(async function test_forward_slashes_in_path_works() {
+ await simpleTest("forwardslash.echo");
+});
+
+// Test sendNativeMessage()
+add_task(async function test_sendNativeMessage() {
+ async function background() {
+ let MSG = { test: "hello world" };
+
+ // Check error handling
+ await browser.test.assertRejects(
+ browser.runtime.sendNativeMessage("nonexistent", MSG),
+ "No such native application nonexistent",
+ "sendNativeMessage() to a nonexistent app failed"
+ );
+
+ // Check regular message exchange
+ let reply = await browser.runtime.sendNativeMessage("echo", MSG);
+
+ let expected = JSON.stringify(MSG);
+ let received = JSON.stringify(reply);
+ browser.test.assertEq(expected, received, "Received echoed native message");
+
+ browser.test.sendMessage("finished");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished");
+
+ // With sendNativeMessage(), the subprocess should be disconnected
+ // after exchanging a single message.
+ await waitForSubprocessExit();
+
+ await extension.unload();
+});
+
+// Test calling Port.disconnect()
+add_task(async function test_disconnect() {
+ function background() {
+ let port = browser.runtime.connectNative("echo");
+ port.onMessage.addListener((msg, msgPort) => {
+ browser.test.assertEq(
+ port,
+ msgPort,
+ "onMessage handler should receive the port as the second argument"
+ );
+ browser.test.sendMessage("message", msg);
+ });
+ port.onDisconnect.addListener(msgPort => {
+ browser.test.fail("onDisconnect should not be called for disconnect()");
+ });
+ browser.test.onMessage.addListener((what, payload) => {
+ if (what == "send") {
+ if (payload._json) {
+ let json = payload._json;
+ payload.toJSON = () => json;
+ delete payload._json;
+ }
+ port.postMessage(payload);
+ } else if (what == "disconnect") {
+ try {
+ port.disconnect();
+ browser.test.assertThrows(
+ () => port.postMessage("void"),
+ "Attempt to postMessage on disconnected port"
+ );
+ browser.test.sendMessage("disconnect-result", { success: true });
+ } catch (err) {
+ browser.test.sendMessage("disconnect-result", {
+ success: false,
+ errmsg: err.message,
+ });
+ }
+ }
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ extension.sendMessage("send", "test");
+ let response = await extension.awaitMessage("message");
+ equal(response, "test", "Echoed a string");
+
+ let procCount = await getSubprocessCount();
+ equal(procCount, 1, "subprocess is running");
+
+ extension.sendMessage("disconnect");
+ response = await extension.awaitMessage("disconnect-result");
+ equal(response.success, true, "disconnect succeeded");
+
+ info("waiting for subprocess to exit");
+ await waitForSubprocessExit();
+ procCount = await getSubprocessCount();
+ equal(procCount, 0, "subprocess is no longer running");
+
+ extension.sendMessage("disconnect");
+ response = await extension.awaitMessage("disconnect-result");
+ equal(response.success, true, "second call to disconnect silently ignored");
+
+ await extension.unload();
+});
+
+// Test the limit on message size for writing
+add_task(async function test_write_limit() {
+ Services.prefs.setIntPref(PREF_MAX_WRITE, 10);
+ function clearPref() {
+ Services.prefs.clearUserPref(PREF_MAX_WRITE);
+ }
+ registerCleanupFunction(clearPref);
+
+ function background() {
+ const PAYLOAD = "0123456789A";
+ let port = browser.runtime.connectNative("echo");
+ try {
+ port.postMessage(PAYLOAD);
+ browser.test.sendMessage("result", null);
+ } catch (ex) {
+ browser.test.sendMessage("result", ex.message);
+ }
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+
+ let errmsg = await extension.awaitMessage("result");
+ notEqual(
+ errmsg,
+ null,
+ "native postMessage() failed for overly large message"
+ );
+
+ await extension.unload();
+ await waitForSubprocessExit();
+
+ clearPref();
+});
+
+// Test the limit on message size for reading
+add_task(async function test_read_limit() {
+ Services.prefs.setIntPref(PREF_MAX_READ, 10);
+ function clearPref() {
+ Services.prefs.clearUserPref(PREF_MAX_READ);
+ }
+ registerCleanupFunction(clearPref);
+
+ function background() {
+ const PAYLOAD = "0123456789A";
+ let port = browser.runtime.connectNative("echo");
+ port.onDisconnect.addListener(msgPort => {
+ browser.test.assertEq(
+ port,
+ msgPort,
+ "onDisconnect handler should receive the port as the first argument"
+ );
+ browser.test.assertEq(
+ "Native application tried to send a message of 13 bytes, which exceeds the limit of 10 bytes.",
+ port.error && port.error.message
+ );
+ browser.test.sendMessage("result", "disconnected");
+ });
+ port.onMessage.addListener(msg => {
+ browser.test.sendMessage("result", "message");
+ });
+ port.postMessage(PAYLOAD);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+
+ let result = await extension.awaitMessage("result");
+ equal(
+ result,
+ "disconnected",
+ "native port disconnected on receiving large message"
+ );
+
+ await extension.unload();
+ await waitForSubprocessExit();
+
+ clearPref();
+});
+
+// Test that an extension without the nativeMessaging permission cannot
+// use native messaging.
+add_task(async function test_ext_permission() {
+ function background() {
+ browser.test.assertEq(
+ chrome.runtime.connectNative,
+ undefined,
+ "chrome.runtime.connectNative does not exist without nativeMessaging permission"
+ );
+ browser.test.assertEq(
+ browser.runtime.connectNative,
+ undefined,
+ "browser.runtime.connectNative does not exist without nativeMessaging permission"
+ );
+ browser.test.assertEq(
+ chrome.runtime.sendNativeMessage,
+ undefined,
+ "chrome.runtime.sendNativeMessage does not exist without nativeMessaging permission"
+ );
+ browser.test.assertEq(
+ browser.runtime.sendNativeMessage,
+ undefined,
+ "browser.runtime.sendNativeMessage does not exist without nativeMessaging permission"
+ );
+ browser.test.sendMessage("finished");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {},
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished");
+ await extension.unload();
+});
+
+// Test that an extension that is not listed in allowed_extensions for
+// a native application cannot use that application.
+add_task(async function test_app_permission() {
+ await testBrokenApp({
+ extensionId: "@id-that-is-not-in-the-allowed_extensions-list",
+ appname: "echo",
+ expectedError: "No such native application echo",
+ expectedConsoleMessages: [
+ /This extension does not have permission to use native manifest .+echo\.json/,
+ /No such native application echo/,
+ ],
+ });
+});
+
+// Test that the command-line arguments and working directory for the
+// native application are as expected.
+add_task(async function test_child_process() {
+ function background() {
+ let port = browser.runtime.connectNative("info");
+ port.onMessage.addListener(msg => {
+ browser.test.sendMessage("result", msg);
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+
+ let msg = await extension.awaitMessage("result");
+ equal(msg.args.length, 3, "Received two command line arguments");
+ equal(
+ msg.args[1],
+ getPath("info.json"),
+ "Command line argument is the path to the native host manifest"
+ );
+ equal(
+ msg.args[2],
+ ID,
+ "Second command line argument is the ID of the calling extension"
+ );
+ equal(
+ msg.cwd.replace(/^\/private\//, "/"),
+ PathUtils.join(tmpDir.path, TYPE_SLUG),
+ "Working directory is the directory containing the native appliation"
+ );
+
+ let exitPromise = waitForSubprocessExit();
+ await extension.unload();
+ await exitPromise;
+});
+
+add_task(async function test_stderr() {
+ function background() {
+ let port = browser.runtime.connectNative("stderr");
+ port.onDisconnect.addListener(msgPort => {
+ browser.test.assertEq(
+ port,
+ msgPort,
+ "onDisconnect handler should receive the port as the first argument"
+ );
+ browser.test.assertEq(
+ null,
+ port.error,
+ "Normal application exit is not an error"
+ );
+ browser.test.sendMessage("finished");
+ });
+ }
+
+ let { messages } = await promiseConsoleOutput(async function () {
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished");
+ await extension.unload();
+
+ await waitForSubprocessExit();
+ });
+
+ let lines = STDERR_LINES.map(line =>
+ messages.findIndex(msg => msg.message.includes(line))
+ );
+ notEqual(lines[0], -1, "Saw first line of stderr output on the console");
+ notEqual(lines[1], -1, "Saw second line of stderr output on the console");
+ notEqual(
+ lines[0],
+ lines[1],
+ "Stderr output lines are separated in the console"
+ );
+});
+
+// Test that calling connectNative() multiple times works
+// (see bug 1313980 for a previous regression in this area)
+add_task(async function test_multiple_connects() {
+ async function background() {
+ function once() {
+ return new Promise(resolve => {
+ let MSG = "hello";
+ let port = browser.runtime.connectNative("echo");
+
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(MSG, msg, "Got expected message back");
+ port.disconnect();
+ resolve();
+ });
+ port.postMessage(MSG);
+ });
+ }
+
+ await once();
+ await once();
+ browser.test.notifyPass("multiple-connect");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("multiple-connect");
+ await extension.unload();
+});
+
+// Test that native messaging is always rejected on content scripts
+add_task(async function test_connect_native_from_content_script() {
+ async function testScript() {
+ let port = browser.runtime.connectNative("echo");
+ port.onDisconnect.addListener(msgPort => {
+ browser.test.assertEq(
+ port,
+ msgPort,
+ "onDisconnect handler should receive the port as the first argument"
+ );
+ browser.test.assertEq(
+ "An unexpected error occurred",
+ port.error && port.error.message
+ );
+ browser.test.sendMessage("result", "disconnected");
+ });
+ port.onMessage.addListener(msg => {
+ browser.test.sendMessage("result", "message");
+ });
+ port.postMessage({ test: "test" });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ run_at: "document_end",
+ js: ["test.js"],
+ matches: ["http://example.com/dummy"],
+ },
+ ],
+ browser_specific_settings: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ files: {
+ "test.js": testScript,
+ },
+ });
+
+ await extension.startup();
+
+ const page = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+
+ let result = await extension.awaitMessage("result");
+ equal(result, "disconnected", "connectNative() failed from content script");
+
+ await page.close();
+ await extension.unload();
+
+ let procCount = await getSubprocessCount();
+ equal(procCount, 0, "No child process was started");
+});
+
+// Testing native app messaging against idle timeout.
+async function startupExtensionAndRequestPermission() {
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ optional_permissions: ["nativeMessaging"],
+ background: { persistent: false },
+ },
+ async background() {
+ browser.runtime.onSuspend.addListener(() => {
+ browser.test.sendMessage("bgpage:suspending");
+ });
+
+ let port;
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ switch (msg) {
+ case "request-permission": {
+ await browser.permissions.request({
+ permissions: ["nativeMessaging"],
+ });
+ break;
+ }
+ case "delayedecho-sendmessage": {
+ browser.runtime
+ .sendNativeMessage("delayedecho", args[0])
+ .then(msg =>
+ browser.test.sendMessage(
+ `delayedecho-sendmessage:got-reply`,
+ msg
+ )
+ );
+ break;
+ }
+ case "connectNative": {
+ if (port) {
+ browser.test.fail(`Unexpected already connected NativeApp port`);
+ } else {
+ port = browser.runtime.connectNative("echo");
+ }
+ break;
+ }
+ case "disconnectNative": {
+ if (!port) {
+ browser.test.fail(`Unexpected undefined NativeApp port`);
+ }
+ port?.disconnect();
+ break;
+ }
+ default:
+ browser.test.fail(`Got an unexpected test message: ${msg}`);
+ }
+
+ browser.test.sendMessage(`${msg}:done`);
+ });
+ browser.test.sendMessage("bg:ready");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("bg:ready");
+ const contextId = extension.extension.backgroundContext.contextId;
+ notEqual(contextId, undefined, "Got a contextId for the background context");
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request-permission");
+ await extension.awaitMessage("request-permission:done");
+ });
+
+ return { extension, contextId };
+}
+
+async function expectTerminateBackgroundToResetIdle({ extension, contextId }) {
+ info("Wait for hasActiveNativeAppPorts to become true");
+ await TestUtils.waitForCondition(
+ () => extension.extension.backgroundContext,
+ "Parent proxy context should be active"
+ );
+
+ await TestUtils.waitForCondition(
+ () => extension.extension.backgroundContext?.hasActiveNativeAppPorts,
+ "Parent proxy context should have active native app ports tracked"
+ );
+
+ clearHistograms();
+ assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT);
+ assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID);
+
+ info("Trigger background script idle timeout and expect to be reset");
+ const promiseResetIdle = promiseExtensionEvent(
+ extension,
+ "background-script-reset-idle"
+ );
+ await extension.terminateBackground();
+ info("Wait for 'background-script-reset-idle' event to be emitted");
+ await promiseResetIdle;
+ equal(
+ extension.extension.backgroundContext.contextId,
+ contextId,
+ "Initial background context is still available as expected"
+ );
+
+ assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, {
+ category: "reset_nativeapp",
+ categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES,
+ });
+
+ assertHistogramCategoryNotEmpty(
+ WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID,
+ {
+ keyed: true,
+ key: extension.id,
+ category: "reset_nativeapp",
+ categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES,
+ }
+ );
+}
+
+async function testSendNativeMessage({ extension, contextId }) {
+ extension.sendMessage("delayedecho-sendmessage", "delayed-echo");
+ await extension.awaitMessage("delayedecho-sendmessage:done");
+
+ await expectTerminateBackgroundToResetIdle({ extension, contextId });
+
+ // We expect exactly two replies (one for the previous queued message
+ // and one more for the last message sent right above).
+ equal(
+ await extension.awaitMessage("delayedecho-sendmessage:got-reply"),
+ "delayed-echo",
+ "Got the expected reply for the first message sent"
+ );
+
+ await TestUtils.waitForCondition(
+ () => !extension.extension.backgroundContext?.hasActiveNativeAppPorts,
+ "Parent proxy context should not have any active native app ports tracked"
+ );
+
+ info("terminating the background script");
+ await extension.terminateBackground();
+ info("wait for runtime.onSuspend listener to have been called");
+ await extension.awaitMessage("bgpage:suspending");
+}
+
+async function testConnectNative({ extension, contextId }) {
+ extension.sendMessage("connectNative");
+ await extension.awaitMessage("connectNative:done");
+
+ await expectTerminateBackgroundToResetIdle({ extension, contextId });
+
+ // Disconnect the NativeApp and confirm that the background page
+ // will be suspending as expected.
+ extension.sendMessage("disconnectNative");
+ await extension.awaitMessage("disconnectNative:done");
+
+ await TestUtils.waitForCondition(
+ () => !extension.extension.backgroundContext?.hasActiveNativeAppPorts,
+ "Parent proxy context should not have any active native app ports tracked"
+ );
+
+ info("terminating the background script");
+ await extension.terminateBackground();
+ info("wait for runtime.onSuspend listener to have been called");
+ await extension.awaitMessage("bgpage:suspending");
+}
+
+add_task(
+ {
+ pref_set: [["extensions.eventPages.enabled", true]],
+ },
+ async function test_pending_sendNativeMessageReply_resets_bgscript_idle_timeout() {
+ const { extension, contextId } =
+ await startupExtensionAndRequestPermission();
+ await testSendNativeMessage({ extension, contextId });
+ await waitForSubprocessExit();
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ pref_set: [["extensions.eventPages.enabled", true]],
+ },
+ async function test_open_connectNativePort_resets_bgscript_idle_timeout() {
+ const { extension, contextId } =
+ await startupExtensionAndRequestPermission();
+ await testConnectNative({ extension, contextId });
+ await waitForSubprocessExit();
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js
new file mode 100644
index 0000000000..7c5d09dc39
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js
@@ -0,0 +1,130 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const MAX_ROUND_TRIP_TIME_MS =
+ AppConstants.DEBUG || AppConstants.ASAN ? 60 : 30;
+const MAX_RETRIES = 5;
+
+const ECHO_BODY = String.raw`
+ import struct
+ import sys
+
+ stdin = getattr(sys.stdin, 'buffer', sys.stdin)
+ stdout = getattr(sys.stdout, 'buffer', sys.stdout)
+
+ while True:
+ rawlen = stdin.read(4)
+ if len(rawlen) == 0:
+ sys.exit(0)
+
+ msglen = struct.unpack('@I', rawlen)[0]
+ msg = stdin.read(msglen)
+
+ stdout.write(struct.pack('@I', msglen))
+ stdout.write(msg)
+`;
+
+const SCRIPTS = [
+ {
+ name: "echo",
+ description: "A native app that echoes back messages it receives",
+ script: ECHO_BODY.replace(/^ {2}/gm, ""),
+ },
+];
+
+add_task(async function setup() {
+ await setupHosts(SCRIPTS);
+});
+
+add_task(async function test_round_trip_perf() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.onMessage.addListener(msg => {
+ if (msg != "run-tests") {
+ return;
+ }
+
+ let port = browser.runtime.connectNative("echo");
+
+ function next() {
+ port.postMessage({
+ Lorem: {
+ ipsum: {
+ dolor: [
+ "sit amet",
+ "consectetur adipiscing elit",
+ "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
+ ],
+ "Ut enim": [
+ "ad minim veniam",
+ "quis nostrud exercitation ullamco",
+ "laboris nisi ut aliquip ex ea commodo consequat.",
+ ],
+ Duis: [
+ "aute irure dolor in reprehenderit in",
+ "voluptate velit esse cillum dolore eu",
+ "fugiat nulla pariatur.",
+ ],
+ Excepteur: [
+ "sint occaecat cupidatat non proident",
+ "sunt in culpa qui officia deserunt",
+ "mollit anim id est laborum.",
+ ],
+ },
+ },
+ });
+ }
+
+ const COUNT = 1000;
+ let now;
+ function finish() {
+ let roundTripTime = (Date.now() - now) / COUNT;
+
+ port.disconnect();
+ browser.test.sendMessage("result", roundTripTime);
+ }
+
+ let count = 0;
+ port.onMessage.addListener(() => {
+ if (count == 0) {
+ // Skip the first round, since it includes the time it takes
+ // the app to start up.
+ now = Date.now();
+ }
+
+ if (count++ <= COUNT) {
+ next();
+ } else {
+ finish();
+ }
+ });
+
+ next();
+ });
+ },
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+
+ let roundTripTime = Infinity;
+ for (
+ let i = 0;
+ i < MAX_RETRIES && roundTripTime > MAX_ROUND_TRIP_TIME_MS;
+ i++
+ ) {
+ extension.sendMessage("run-tests");
+ roundTripTime = await extension.awaitMessage("result");
+ }
+
+ await extension.unload();
+
+ ok(
+ roundTripTime <= MAX_ROUND_TRIP_TIME_MS,
+ `Expected round trip time (${roundTripTime}ms) to be less than ${MAX_ROUND_TRIP_TIME_MS}ms`
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js
new file mode 100644
index 0000000000..5b30a06a23
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js
@@ -0,0 +1,85 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const WONTDIE_BODY = String.raw`
+ import signal
+ import struct
+ import sys
+ import time
+
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
+
+ stdin = getattr(sys.stdin, 'buffer', sys.stdin)
+ stdout = getattr(sys.stdout, 'buffer', sys.stdout)
+
+ def spin():
+ while True:
+ try:
+ signal.pause()
+ except AttributeError:
+ time.sleep(5)
+
+ while True:
+ rawlen = stdin.read(4)
+ if len(rawlen) == 0:
+ spin()
+
+ msglen = struct.unpack('@I', rawlen)[0]
+ msg = stdin.read(msglen)
+
+ stdout.write(struct.pack('@I', msglen))
+ stdout.write(msg)
+`;
+
+const SCRIPTS = [
+ {
+ name: "wontdie",
+ description:
+ "a native app that does not exit when stdin closes or on SIGTERM",
+ script: WONTDIE_BODY.replace(/^ {2}/gm, ""),
+ },
+];
+
+add_task(async function setup() {
+ await setupHosts(SCRIPTS);
+});
+
+// Test that an unresponsive native application still gets killed eventually
+add_task(async function test_unresponsive_native_app() {
+ // XXX expose GRACEFUL_SHUTDOWN_TIME as a pref and reduce it
+ // just for this test?
+
+ function background() {
+ let port = browser.runtime.connectNative("wontdie");
+
+ const MSG = "echo me";
+ // bounce a message to make sure the process actually starts
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, MSG, "Received echoed message");
+ browser.test.sendMessage("ready");
+ });
+ port.postMessage(MSG);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ let procCount = await getSubprocessCount();
+ equal(procCount, 1, "subprocess is running");
+
+ let exitPromise = waitForSubprocessExit();
+ await extension.unload();
+ await exitPromise;
+
+ procCount = await getSubprocessCount();
+ equal(procCount, 0, "subprocess was successfully killed");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js b/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js
new file mode 100644
index 0000000000..5d94a1534d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js
@@ -0,0 +1,209 @@
+"use strict";
+
+const Cm = Components.manager;
+
+const uuidGenerator = Services.uuid;
+
+AddonTestUtils.init(this);
+
+var mockNetworkStatusService = {
+ contractId: "@mozilla.org/network/network-link-service;1",
+
+ _mockClassId: uuidGenerator.generateUUID(),
+
+ _originalClassId: "",
+
+ QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]),
+
+ createInstance(iiD) {
+ return this.QueryInterface(iiD);
+ },
+
+ register() {
+ let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+ if (!registrar.isCIDRegistered(this._mockClassId)) {
+ this._originalClassId = registrar.contractIDToCID(this.contractId);
+ registrar.registerFactory(
+ this._mockClassId,
+ "Unregister after testing",
+ this.contractId,
+ this
+ );
+ }
+ },
+
+ unregister() {
+ let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+ registrar.unregisterFactory(this._mockClassId, this);
+ registrar.registerFactory(this._originalClassId, "", this.contractId, null);
+ },
+
+ _isLinkUp: true,
+ _linkStatusKnown: false,
+ _linkType: Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN,
+
+ get isLinkUp() {
+ return this._isLinkUp;
+ },
+
+ get linkStatusKnown() {
+ return this._linkStatusKnown;
+ },
+
+ setLinkStatus(status) {
+ switch (status) {
+ case "up":
+ this._isLinkUp = true;
+ this._linkStatusKnown = true;
+ this._networkID = "foo";
+ break;
+ case "down":
+ this._isLinkUp = false;
+ this._linkStatusKnown = true;
+ this._linkType = Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN;
+ this._networkID = undefined;
+ break;
+ case "changed":
+ this._linkStatusKnown = true;
+ this._networkID = "foo";
+ break;
+ case "unknown":
+ this._linkStatusKnown = false;
+ this._linkType = Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN;
+ this._networkID = undefined;
+ break;
+ }
+ Services.obs.notifyObservers(null, "network:link-status-changed", status);
+ },
+
+ get linkType() {
+ return this._linkType;
+ },
+
+ setLinkType(val) {
+ this._linkType = val;
+ this._linkStatusKnown = true;
+ this._isLinkUp = true;
+ this._networkID = "bar";
+ Services.obs.notifyObservers(
+ null,
+ "network:link-type-changed",
+ this._linkType
+ );
+ },
+
+ get networkID() {
+ return this._networkID;
+ },
+};
+
+// nsINetworkLinkService is not directly testable. With the mock service above,
+// we just exercise a couple small things here to validate the api works somewhat.
+add_task(async function test_networkStatus() {
+ mockNetworkStatusService.register();
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "networkstatus@tests.mozilla.org" },
+ },
+ permissions: ["networkStatus"],
+ },
+ isPrivileged: true,
+ async background() {
+ browser.networkStatus.onConnectionChanged.addListener(async details => {
+ browser.test.log(`connection status ${JSON.stringify(details)}`);
+ browser.test.sendMessage("connect-changed", {
+ details,
+ linkInfo: await browser.networkStatus.getLinkInfo(),
+ });
+ });
+ browser.test.sendMessage(
+ "linkdata",
+ await browser.networkStatus.getLinkInfo()
+ );
+ },
+ });
+
+ async function test(expected, change) {
+ if (change.status) {
+ info(`test link change status to ${change.status}`);
+ mockNetworkStatusService.setLinkStatus(change.status);
+ } else if (change.link) {
+ info(`test link change type to ${change.link}`);
+ mockNetworkStatusService.setLinkType(change.link);
+ }
+ let { details, linkInfo } = await extension.awaitMessage("connect-changed");
+ equal(details.type, expected.type, "network type is correct");
+ equal(details.status, expected.status, `network status is correct`);
+ equal(details.id, expected.id, "network id");
+ Assert.deepEqual(
+ linkInfo,
+ details,
+ "getLinkInfo should resolve to the same details received from onConnectionChanged"
+ );
+ }
+
+ await extension.startup();
+
+ let data = await extension.awaitMessage("linkdata");
+ equal(data.type, "unknown", "network type is unknown");
+ equal(data.status, "unknown", `network status is ${data.status}`);
+ equal(data.id, undefined, "network id");
+
+ await test(
+ { type: "unknown", status: "up", id: "foo" },
+ { status: "changed" }
+ );
+
+ await test(
+ { type: "wifi", status: "up", id: "bar" },
+ { link: Ci.nsINetworkLinkService.LINK_TYPE_WIFI }
+ );
+
+ await test({ type: "unknown", status: "down" }, { status: "down" });
+
+ await test({ type: "unknown", status: "unknown" }, { status: "unknown" });
+
+ await extension.unload();
+ mockNetworkStatusService.unregister();
+});
+
+add_task(
+ {
+ // Some builds (e.g. thunderbird) have experiments enabled by default.
+ pref_set: [["extensions.experiments.enabled", false]],
+ },
+ async function test_networkStatus_permission() {
+ let extension = ExtensionTestUtils.loadExtension({
+ temporarilyInstalled: true,
+ isPrivileged: false,
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "networkstatus-permission@tests.mozilla.org" },
+ },
+ permissions: ["networkStatus"],
+ },
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ let { messages } = await promiseConsoleOutput(async () => {
+ await Assert.rejects(
+ extension.startup(),
+ /Using the privileged permission/,
+ "Startup failed with privileged permission"
+ );
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ AddonTestUtils.checkMessages(
+ messages,
+ {
+ expected: [
+ {
+ message:
+ /Using the privileged permission 'networkStatus' requires a privileged add-on/,
+ },
+ ],
+ },
+ true
+ );
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js
new file mode 100644
index 0000000000..fda60c3a82
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js
@@ -0,0 +1,105 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1";
+
+const createdAlerts = [];
+
+const mockAlertsService = {
+ showPersistentNotification(persistentData, alert, alertListener) {
+ this.showAlert(alert, alertListener);
+ },
+
+ showAlert(alert, listener) {
+ createdAlerts.push(alert);
+ listener.observe(null, "alertfinished", alert.cookie);
+ },
+
+ showAlertNotification(
+ imageUrl,
+ title,
+ text,
+ textClickable,
+ cookie,
+ alertListener,
+ name,
+ dir,
+ lang,
+ data,
+ principal,
+ privateBrowsing
+ ) {
+ this.showAlert({ cookie, title, text, privateBrowsing }, alertListener);
+ },
+
+ closeAlert(name) {
+ // This mock immediately close the alert on show, so this is empty.
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIAlertsService"]),
+
+ createInstance(iid) {
+ return this.QueryInterface(iid);
+ },
+};
+
+const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+registrar.registerFactory(
+ Components.ID("{173a036a-d678-4415-9cff-0baff6bfe554}"),
+ "alerts service",
+ ALERTS_SERVICE_CONTRACT_ID,
+ mockAlertsService
+);
+
+add_task(async function test_notification_privateBrowsing_flag() {
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ permissions: ["notifications"],
+ },
+ files: {
+ "page.html": `<meta charset="utf-8"><script src="page.js"></script>`,
+ async "page.js"() {
+ let closedPromise = new Promise(resolve => {
+ browser.notifications.onClosed.addListener(resolve);
+ });
+ let createdId = await browser.notifications.create("notifid", {
+ type: "basic",
+ title: "titl",
+ message: "msg",
+ });
+ let closedId = await closedPromise;
+ browser.test.assertEq(createdId, closedId, "ID of closed notification");
+ browser.test.assertEq(
+ "{}",
+ JSON.stringify(await browser.notifications.getAll()),
+ "no notifications left"
+ );
+ browser.test.sendMessage("notification_closed");
+ },
+ },
+ });
+ await extension.startup();
+
+ async function checkPrivateBrowsingFlag(privateBrowsing) {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/page.html`,
+ { extension, remote: extension.extension.remote, privateBrowsing }
+ );
+ await extension.awaitMessage("notification_closed");
+ await contentPage.close();
+
+ Assert.equal(createdAlerts.length, 1, "expected one alert");
+ let notification = createdAlerts.shift();
+ Assert.equal(notification.cookie, "notifid", "notification id");
+ Assert.equal(notification.title, "titl", "notification title");
+ Assert.equal(notification.text, "msg", "notification text");
+ Assert.equal(notification.privateBrowsing, privateBrowsing, "pbm flag");
+ }
+
+ await checkPrivateBrowsingFlag(false);
+ await checkPrivateBrowsingFlag(true);
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js
new file mode 100644
index 0000000000..1213ae4f23
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js
@@ -0,0 +1,41 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1";
+const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+registrar.registerFactory(
+ Components.ID("{18f25bb4-ab12-4e24-b3b0-69215056160b}"),
+ "unsupported alerts service",
+ ALERTS_SERVICE_CONTRACT_ID,
+ {} // This object lacks an implementation of nsIAlertsService.
+);
+
+add_task(async function test_notification_unsupported_backend() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ async background() {
+ let closedPromise = new Promise(resolve => {
+ browser.notifications.onClosed.addListener(resolve);
+ });
+ let createdId = await browser.notifications.create("notifid", {
+ type: "basic",
+ title: "titl",
+ message: "msg",
+ });
+ let closedId = await closedPromise;
+ browser.test.assertEq(createdId, closedId, "ID of closed notification");
+ browser.test.assertEq(
+ "{}",
+ JSON.stringify(await browser.notifications.getAll()),
+ "no notifications left"
+ );
+ browser.test.sendMessage("notification_closed");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("notification_closed");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js b/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js
new file mode 100644
index 0000000000..c6d258c96c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js
@@ -0,0 +1,30 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+function backgroundScript() {
+ function listener() {
+ browser.test.notifyFail("listener should not be invoked");
+ }
+
+ browser.runtime.onMessage.addListener(listener);
+ browser.runtime.onMessage.removeListener(listener);
+ browser.runtime.sendMessage("hello");
+
+ // Make sure that, if we somehow fail to remove the listener, then we'll run
+ // the listener before the test is marked as passing.
+ setTimeout(function () {
+ browser.test.notifyPass("onmessage_removelistener");
+ }, 0);
+}
+
+let extensionData = {
+ background: backgroundScript,
+};
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitFinish("onmessage_removelistener");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js b/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js
new file mode 100644
index 0000000000..1a14ff6eb6
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js
@@ -0,0 +1,86 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+const ENABLE_COUNTER_PREF =
+ "extensions.webextensions.enablePerformanceCounters";
+const TIMING_MAX_AGE = "extensions.webextensions.performanceCountersMaxAge";
+
+let { ParentAPIManager } = ExtensionParent;
+
+function sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms)); // eslint-disable-line mozilla/no-arbitrary-setTimeout
+}
+
+async function retrieveSpecificCounter(apiName, expectedCount) {
+ let currentCount = 0;
+ let data;
+ while (currentCount < expectedCount) {
+ data = await ParentAPIManager.retrievePerformanceCounters();
+ for (let [console, counters] of data) {
+ for (let [api, counter] of counters) {
+ if (api == apiName) {
+ currentCount += counter.calls;
+ }
+ }
+ }
+ await sleep(100);
+ }
+ return data;
+}
+
+async function test_counter() {
+ async function background() {
+ // creating a bookmark is done in the parent
+ let folder = await browser.bookmarks.create({ title: "Folder" });
+ await browser.bookmarks.create({
+ title: "Bookmark",
+ url: "http://example.com",
+ parentId: folder.id,
+ });
+
+ // getURL() is done in the child, let do three
+ browser.runtime.getURL("beasts/frog.html");
+ browser.runtime.getURL("beasts/frog2.html");
+ browser.runtime.getURL("beasts/frog3.html");
+ browser.test.sendMessage("done");
+ }
+
+ let extensionData = {
+ background,
+ manifest: {
+ permissions: ["bookmarks"],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("done");
+
+ let counters = await retrieveSpecificCounter("getURL", 3);
+ await extension.unload();
+
+ // check that the bookmarks.create API was tracked
+ let counter = counters.get(extension.id).get("bookmarks.create");
+ ok(counter.calls > 0);
+ ok(counter.duration > 0);
+
+ // check that the getURL API was tracked
+ counter = counters.get(extension.id).get("getURL");
+ ok(counter.calls > 0);
+ ok(counter.duration > 0);
+}
+
+add_task(function test_performance_counter() {
+ return runWithPrefs(
+ [
+ [ENABLE_COUNTER_PREF, true],
+ [TIMING_MAX_AGE, 1],
+ ],
+ test_counter
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js b/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js
new file mode 100644
index 0000000000..33248611fd
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js
@@ -0,0 +1,845 @@
+"use strict";
+
+let { ExtensionTestCommon } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionTestCommon.sys.mjs"
+);
+
+const {
+ PERMISSION_L10N_ID_OVERRIDES,
+ PERMISSIONS_WITH_MESSAGE,
+ permissionToL10nId,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissionMessages.sys.mjs"
+);
+
+const EXTENSION_L10N_PATHS = [
+ "toolkit/global/extensions.ftl",
+ "toolkit/global/extensionPermissions.ftl",
+ "branding/brand.ftl",
+];
+
+// For Android, these strings are only used in tests. In the actual UI, the
+// warnings are in Android-Components, as explained in bug 1671453.
+const l10n = new Localization(EXTENSION_L10N_PATHS, true);
+
+// nativeMessaging is in PRIVILEGED_PERMS on Android.
+const IS_NATIVE_MESSAGING_PRIVILEGED = AppConstants.platform == "android";
+
+const { createAppInfo } = AddonTestUtils;
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged");
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+async function getManifestPermissions(extensionData) {
+ let extension = ExtensionTestCommon.generate(extensionData);
+ // Some tests contain invalid permissions; ignore the warnings about their invalidity.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.loadManifest();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ let result = extension.manifestPermissions;
+
+ if (extension.manifest.manifest_version >= 3) {
+ // In MV3, host permissions are optional by default.
+ deepEqual(result.origins, [], "No origins by default in MV3");
+ let optional = extension.manifestOptionalPermissions;
+ deepEqual(optional.permissions, [], "No tests use optional_permissions");
+ result.origins = optional.origins;
+ }
+
+ await extension.cleanupGeneratedFile();
+ return result;
+}
+
+function getPermissionWarnings(permissions, options) {
+ let { msgs } = ExtensionData.formatPermissionStrings(
+ { permissions },
+ options
+ );
+ return msgs;
+}
+
+async function getPermissionWarningsForUpdate(
+ oldExtensionData,
+ newExtensionData
+) {
+ let oldPerms = await getManifestPermissions(oldExtensionData);
+ let newPerms = await getManifestPermissions(newExtensionData);
+ let difference = Extension.comparePermissions(oldPerms, newPerms);
+ return getPermissionWarnings(difference);
+}
+
+// Tests that the callers of ExtensionData.formatPermissionStrings can customize the
+// mapping between the permission names and related localized strings.
+add_task(async function customized_permission_keys_mapping() {
+ const mockLocalization = {
+ formatMessagesSync: args =>
+ args.map(arg => ({
+ value: `Fake localized ${arg.id ?? arg}`,
+ attributes: [],
+ })),
+ formatValueSync: key => `Fake localized ${key}`,
+ formatValuesSync: args =>
+ args.map(arg => `Fake localized ${arg.id ?? arg}`),
+ };
+
+ // Define a non-default mapping for permission names -> locale keys.
+ const getKeyForPermission = perm => `custom-webext-perms-description-${perm}`;
+
+ const manifest = {
+ permissions: ["downloads", "proxy"],
+ };
+ const expectedWarnings = mockLocalization.formatValuesSync(
+ manifest.permissions.map(getKeyForPermission)
+ );
+
+ try {
+ for (let perm of manifest.permissions) {
+ PERMISSION_L10N_ID_OVERRIDES.set(perm, getKeyForPermission(perm));
+ }
+ const manifestPermissions = await getManifestPermissions({ manifest });
+
+ // Pass the callback function for the non-default key mapping to
+ // ExtensionData.formatPermissionStrings() and verify it being used.
+ const warnings = getPermissionWarnings(manifestPermissions, {
+ localization: mockLocalization,
+ });
+ deepEqual(
+ warnings,
+ expectedWarnings,
+ "Got the expected string from customized permission mapping"
+ );
+ } finally {
+ for (let perm of manifest.permissions) {
+ PERMISSION_L10N_ID_OVERRIDES.delete(perm);
+ }
+ }
+});
+
+// Tests that permission description data is internally consistent
+add_task(async function permission_message_consistence() {
+ for (let perm of PERMISSIONS_WITH_MESSAGE) {
+ ok(permissionToL10nId(perm), `Message is provided for ${perm}`);
+ }
+ for (let [perm] of PERMISSION_L10N_ID_OVERRIDES) {
+ ok(permissionToL10nId(perm), `Message is provided for ${perm}`);
+ }
+});
+
+// Tests that the expected permission warnings are generated for various
+// combinations of host permissions.
+add_task(async function host_permissions() {
+ let permissionTestCases = [
+ {
+ description: "Empty manifest without permissions",
+ manifest: {},
+ expectedOrigins: [],
+ expectedWarnings: [],
+ },
+ {
+ description: "Invalid match patterns",
+ manifest: {
+ permissions: [
+ "https:///",
+ "https://",
+ "https://*",
+ "about:ugh",
+ "about:*",
+ "about://*/",
+ "resource://*/",
+ ],
+ },
+ expectedOrigins: [],
+ expectedWarnings: [],
+ },
+ {
+ description: "moz-extension: permissions",
+ manifest: {
+ permissions: ["moz-extension://*/*", "moz-extension://uuid/"],
+ },
+ // moz-extension:-origin does not appear in the permission list,
+ // but it is implicitly granted anyway.
+ expectedOrigins: [],
+ expectedWarnings: [],
+ },
+ {
+ description: "*. host permission",
+ manifest: {
+ // This permission is rejected by the manifest and ignored.
+ permissions: ["http://*./"],
+ },
+ expectedOrigins: [],
+ expectedWarnings: [],
+ },
+ {
+ description: "<all_urls> permission",
+ manifest: {
+ permissions: ["<all_urls>"],
+ },
+ expectedOrigins: ["<all_urls>"],
+ expectedWarnings: [
+ l10n.formatValueSync("webext-perms-host-description-all-urls"),
+ ],
+ },
+ {
+ description: "file: permissions",
+ manifest: {
+ permissions: ["file://*/"],
+ },
+ expectedOrigins: ["file://*/"],
+ expectedWarnings: [
+ l10n.formatValueSync("webext-perms-host-description-all-urls"),
+ ],
+ },
+ {
+ description: "http: permission",
+ manifest: {
+ permissions: ["http://*/"],
+ },
+ expectedOrigins: ["http://*/"],
+ expectedWarnings: [
+ l10n.formatValueSync("webext-perms-host-description-all-urls"),
+ ],
+ },
+ {
+ description: "*://*/ permission",
+ manifest: {
+ permissions: ["*://*/"],
+ },
+ expectedOrigins: ["*://*/"],
+ expectedWarnings: [
+ l10n.formatValueSync("webext-perms-host-description-all-urls"),
+ ],
+ },
+ {
+ description: "content_script[*].matches",
+ manifest: {
+ content_scripts: [
+ {
+ // This test uses the manifest file without loading the content script
+ // file, so we can use a non-existing dummy file.
+ js: ["dummy.js"],
+ matches: ["https://*/"],
+ },
+ ],
+ },
+ expectedOrigins: ["https://*/"],
+ expectedWarnings: [
+ l10n.formatValueSync("webext-perms-host-description-all-urls"),
+ ],
+ },
+ {
+ description: "A few host permissions",
+ manifest: {
+ permissions: ["http://a/", "http://*.b/", "http://c/*"],
+ },
+ expectedOrigins: ["http://a/", "http://*.b/", "http://c/*"],
+ expectedWarnings: l10n.formatValuesSync([
+ // Wildcard hosts take precedence in the permission list.
+ {
+ id: "webext-perms-host-description-wildcard",
+ args: { domain: "b" },
+ },
+ {
+ id: "webext-perms-host-description-one-site",
+ args: { domain: "a" },
+ },
+ {
+ id: "webext-perms-host-description-one-site",
+ args: { domain: "c" },
+ },
+ ]),
+ },
+ {
+ description: "many host permission",
+ manifest: {
+ permissions: [
+ "http://a/",
+ "http://b/",
+ "http://c/",
+ "http://d/",
+ "http://e/*",
+ "http://*.1/",
+ "http://*.2/",
+ "http://*.3/",
+ "http://*.4/",
+ ],
+ },
+ expectedOrigins: [
+ "http://a/",
+ "http://b/",
+ "http://c/",
+ "http://d/",
+ "http://e/*",
+ "http://*.1/",
+ "http://*.2/",
+ "http://*.3/",
+ "http://*.4/",
+ ],
+ expectedWarnings: l10n.formatValuesSync([
+ // Wildcard hosts take precedence in the permission list.
+ {
+ id: "webext-perms-host-description-wildcard",
+ args: { domain: "1" },
+ },
+ {
+ id: "webext-perms-host-description-wildcard",
+ args: { domain: "2" },
+ },
+ {
+ id: "webext-perms-host-description-wildcard",
+ args: { domain: "3" },
+ },
+ {
+ id: "webext-perms-host-description-wildcard",
+ args: { domain: "4" },
+ },
+ {
+ id: "webext-perms-host-description-one-site",
+ args: { domain: "a" },
+ },
+ {
+ id: "webext-perms-host-description-one-site",
+ args: { domain: "b" },
+ },
+ {
+ id: "webext-perms-host-description-one-site",
+ args: { domain: "c" },
+ },
+ {
+ id: "webext-perms-host-description-too-many-sites",
+ args: { domainCount: 2 },
+ },
+ ]),
+ options: {
+ collapseOrigins: true,
+ },
+ },
+ {
+ description:
+ "many host permissions without item limit in the warning list",
+ manifest: {
+ permissions: [
+ "http://a/",
+ "http://b/",
+ "http://c/",
+ "http://d/",
+ "http://e/*",
+ "http://*.1/",
+ "http://*.2/",
+ "http://*.3/",
+ "http://*.4/",
+ "http://*.5/",
+ ],
+ },
+ expectedOrigins: [
+ "http://a/",
+ "http://b/",
+ "http://c/",
+ "http://d/",
+ "http://e/*",
+ "http://*.1/",
+ "http://*.2/",
+ "http://*.3/",
+ "http://*.4/",
+ "http://*.5/",
+ ],
+ expectedWarnings: l10n.formatValuesSync([
+ { id: "webext-perms-host-description-wildcard", args: { domain: "1" } },
+ { id: "webext-perms-host-description-wildcard", args: { domain: "2" } },
+ { id: "webext-perms-host-description-wildcard", args: { domain: "3" } },
+ { id: "webext-perms-host-description-wildcard", args: { domain: "4" } },
+ { id: "webext-perms-host-description-wildcard", args: { domain: "5" } },
+ { id: "webext-perms-host-description-one-site", args: { domain: "a" } },
+ { id: "webext-perms-host-description-one-site", args: { domain: "b" } },
+ { id: "webext-perms-host-description-one-site", args: { domain: "c" } },
+ { id: "webext-perms-host-description-one-site", args: { domain: "d" } },
+ { id: "webext-perms-host-description-one-site", args: { domain: "e" } },
+ ]),
+ },
+ ];
+ for (let manifest_version of [2, 3]) {
+ for (let {
+ description,
+ manifest,
+ expectedOrigins,
+ expectedWarnings,
+ options,
+ } of permissionTestCases) {
+ manifest = Object.assign({}, manifest, { manifest_version });
+ if (manifest_version > 2) {
+ manifest.host_permissions = manifest.permissions;
+ manifest.permissions = [];
+ }
+
+ let manifestPermissions = await getManifestPermissions({ manifest });
+
+ deepEqual(
+ manifestPermissions.origins,
+ expectedOrigins,
+ `Expected origins (${description})`
+ );
+ deepEqual(
+ manifestPermissions.permissions,
+ [],
+ `Expected no non-host permissions (${description})`
+ );
+
+ let warnings = getPermissionWarnings(manifestPermissions, options);
+ deepEqual(
+ warnings,
+ expectedWarnings,
+ `Expected warnings (${description})`
+ );
+ }
+ }
+});
+
+// Tests that the expected permission warnings are generated for a mix of host
+// permissions and API permissions.
+add_task(async function api_permissions() {
+ let manifestPermissions = await getManifestPermissions({
+ isPrivileged: IS_NATIVE_MESSAGING_PRIVILEGED,
+ manifest: {
+ permissions: [
+ "activeTab",
+ "webNavigation",
+ "tabs",
+ "nativeMessaging",
+ "http://x/",
+ "http://*.x/",
+ "http://*.tld/",
+ ],
+ },
+ });
+
+ deepEqual(
+ manifestPermissions,
+ {
+ origins: ["http://x/", "http://*.x/", "http://*.tld/"],
+ permissions: ["activeTab", "webNavigation", "tabs", "nativeMessaging"],
+ },
+ "Expected origins and permissions"
+ );
+
+ deepEqual(
+ getPermissionWarnings(manifestPermissions),
+ l10n.formatValuesSync([
+ // Host permissions first, with wildcards on top.
+ { id: "webext-perms-host-description-wildcard", args: { domain: "x" } },
+ { id: "webext-perms-host-description-wildcard", args: { domain: "tld" } },
+ { id: "webext-perms-host-description-one-site", args: { domain: "x" } },
+ // nativeMessaging permission warning first of all permissions.
+ "webext-perms-description-nativeMessaging",
+ // Other permissions in alphabetical order.
+ // Note: activeTab has no permission warning string.
+ "webext-perms-description-tabs",
+ "webext-perms-description-webNavigation",
+ ]),
+ "Expected warnings"
+ );
+});
+
+add_task(async function nativeMessaging_permission() {
+ let manifestPermissions = await getManifestPermissions({
+ // isPrivileged: false, by default.
+ manifest: {
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ if (IS_NATIVE_MESSAGING_PRIVILEGED) {
+ // The behavior of nativeMessaging for unprivileged extensions on Android
+ // is covered in
+ // mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_permissions.js
+ deepEqual(
+ manifestPermissions,
+ { origins: [], permissions: [] },
+ "nativeMessaging perm ignored for unprivileged extensions on Android"
+ );
+ } else {
+ deepEqual(
+ manifestPermissions,
+ { origins: [], permissions: ["nativeMessaging"] },
+ "nativeMessaging permission recognized for unprivileged extensions"
+ );
+ }
+});
+
+add_task(
+ { pref_set: [["extensions.dnr.enabled", true]] },
+ async function declarativeNetRequest_permission_with_warning() {
+ let manifestPermissions = await getManifestPermissions({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ },
+ });
+
+ deepEqual(
+ manifestPermissions,
+ {
+ origins: [],
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ },
+ "Expected origins and permissions"
+ );
+
+ deepEqual(
+ getPermissionWarnings(manifestPermissions),
+ l10n.formatValuesSync([
+ "webext-perms-description-declarativeNetRequest",
+ "webext-perms-description-declarativeNetRequestFeedback",
+ ]),
+ "Expected warnings"
+ );
+ }
+);
+
+add_task(
+ { pref_set: [["extensions.dnr.enabled", true]] },
+ async function declarativeNetRequest_permission_without_warning() {
+ let manifestPermissions = await getManifestPermissions({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequestWithHostAccess"],
+ },
+ });
+
+ deepEqual(
+ manifestPermissions,
+ { origins: [], permissions: ["declarativeNetRequestWithHostAccess"] },
+ "Expected origins and permissions"
+ );
+
+ deepEqual(getPermissionWarnings(manifestPermissions), [], "No warnings");
+ }
+);
+
+// Tests that the expected permission warnings are generated for a mix of host
+// permissions and API permissions, for a privileged extension that uses the
+// mozillaAddons permission.
+add_task(async function privileged_with_mozillaAddons() {
+ let manifestPermissions = await getManifestPermissions({
+ isPrivileged: true,
+ manifest: {
+ permissions: [
+ "mozillaAddons",
+ "mozillaAddons",
+ "mozillaAddons",
+ "resource://x/*",
+ "http://a/",
+ "about:reader*",
+ ],
+ },
+ });
+ deepEqual(
+ manifestPermissions,
+ {
+ origins: ["resource://x/*", "http://a/", "about:reader*"],
+ permissions: ["mozillaAddons"],
+ },
+ "Expected origins and permissions for privileged add-on with mozillaAddons"
+ );
+
+ deepEqual(
+ getPermissionWarnings(manifestPermissions),
+ [l10n.formatValueSync("webext-perms-host-description-all-urls")],
+ "Expected warnings for privileged add-on with mozillaAddons permission."
+ );
+});
+
+// Similar to the privileged_with_mozillaAddons test, except the test extension
+// is unprivileged and not allowed to use the mozillaAddons permission.
+add_task(async function unprivileged_with_mozillaAddons() {
+ let manifestPermissions = await getManifestPermissions({
+ manifest: {
+ permissions: [
+ "mozillaAddons",
+ "mozillaAddons",
+ "mozillaAddons",
+ "resource://x/*",
+ "http://a/",
+ "about:reader*",
+ ],
+ },
+ });
+ deepEqual(
+ manifestPermissions,
+ {
+ origins: ["http://a/"],
+ permissions: [],
+ },
+ "Expected origins and permissions for unprivileged add-on with mozillaAddons"
+ );
+
+ deepEqual(
+ getPermissionWarnings(manifestPermissions),
+ [
+ l10n.formatValueSync("webext-perms-host-description-one-site", {
+ domain: "a",
+ }),
+ ],
+ "Expected warnings for unprivileged add-on with mozillaAddons permission."
+ );
+});
+
+// Tests that an update with less permissions has no warning.
+add_task(async function update_drop_permission() {
+ let warnings = await getPermissionWarningsForUpdate(
+ {
+ manifest: {
+ permissions: ["<all_urls>", "https://a/", "http://b/"],
+ },
+ },
+ {
+ manifest: {
+ permissions: [
+ "https://a/",
+ "http://b/",
+ "ftp://host_matching_all_urls/",
+ ],
+ },
+ }
+ );
+ deepEqual(
+ warnings,
+ [],
+ "An update with fewer permissions should not have any warnings"
+ );
+});
+
+// Tests that an update that switches from "*://*/*" to "<all_urls>" does not
+// result in additional permission warnings.
+add_task(async function update_all_urls_permission() {
+ let warnings = await getPermissionWarningsForUpdate(
+ {
+ manifest: {
+ permissions: ["*://*/*"],
+ },
+ },
+ {
+ manifest: {
+ permissions: ["<all_urls>"],
+ },
+ }
+ );
+ deepEqual(
+ warnings,
+ [],
+ "An update from a wildcard host to <all_urls> should not have any warnings"
+ );
+});
+
+// Tests that an update where a new permission whose domain overlaps with
+// an existing permission does not result in additional permission warnings.
+add_task(async function update_change_permissions() {
+ let warnings = await getPermissionWarningsForUpdate(
+ {
+ manifest: {
+ permissions: ["https://a/", "http://*.b/", "http://c/", "http://f/"],
+ },
+ },
+ {
+ manifest: {
+ permissions: [
+ // (no new warning) Unchanged permission from old extension.
+ "https://a/",
+ // (no new warning) Different schemes, host should match "*.b" wildcard.
+ "ftp://ftp.b/",
+ "ws://ws.b/",
+ "wss://wss.b",
+ "https://https.b/",
+ "http://http.b/",
+ "*://*.b/",
+ "http://b/",
+
+ // (expect warning) Wildcard was added.
+ "http://*.c/",
+ // (no new warning) file:-scheme, but host "f" is same as "http://f/".
+ "file://f/",
+ // (expect warning) New permission was added.
+ "proxy",
+ ],
+ },
+ }
+ );
+ deepEqual(
+ warnings,
+ l10n.formatValuesSync([
+ { id: "webext-perms-host-description-wildcard", args: { domain: "c" } },
+ "webext-perms-description-proxy",
+ ]),
+ "Expected permission warnings for new permissions only"
+ );
+});
+
+// Tests that a privileged extension with the mozillaAddons permission can be
+// updated without errors.
+add_task(async function update_privileged_with_mozillaAddons() {
+ let warnings = await getPermissionWarningsForUpdate(
+ {
+ isPrivileged: true,
+ manifest: {
+ permissions: ["mozillaAddons", "resource://a/"],
+ },
+ },
+ {
+ isPrivileged: true,
+ manifest: {
+ permissions: ["mozillaAddons", "resource://a/", "resource://b/"],
+ },
+ }
+ );
+ deepEqual(
+ warnings,
+ [
+ l10n.formatValueSync("webext-perms-host-description-one-site", {
+ domain: "b",
+ }),
+ ],
+ "Expected permission warnings for new host only"
+ );
+});
+
+// Tests that an unprivileged extension cannot get privileged permissions
+// through an update.
+add_task(async function update_unprivileged_with_mozillaAddons() {
+ // Unprivileged
+ let warnings = await getPermissionWarningsForUpdate(
+ {
+ manifest: {
+ permissions: ["mozillaAddons", "resource://a/"],
+ },
+ },
+ {
+ manifest: {
+ permissions: ["mozillaAddons", "resource://a/", "resource://b/"],
+ },
+ }
+ );
+ deepEqual(
+ warnings,
+ [],
+ "resource:-scheme is unsupported for unprivileged extensions"
+ );
+});
+
+// Tests that invalid permission warning for privileged permissions requested
+// are not emitted for privileged extensions, only for unprivileged extensions.
+add_task(
+ async function test_invalid_permission_warning_on_privileged_permission() {
+ await AddonTestUtils.promiseStartupManager();
+
+ const MANIFEST_WARNINGS = [
+ "Reading manifest: Invalid extension permission: mozillaAddons",
+ "Reading manifest: Invalid extension permission: resource://x/",
+ "Reading manifest: Invalid extension permission: about:reader*",
+ ];
+
+ async function testInvalidPermissionWarning({ isPrivileged }) {
+ let id = isPrivileged
+ ? "privileged-addon@mochi.test"
+ : "nonprivileged-addon@mochi.test";
+
+ let expectedWarnings = isPrivileged ? [] : MANIFEST_WARNINGS;
+
+ const ext = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["mozillaAddons", "resource://x/", "about:reader*"],
+ browser_specific_settings: { gecko: { id } },
+ },
+ background() {},
+ });
+
+ await ext.startup();
+ const { warnings } = ext.extension;
+ Assert.deepEqual(
+ warnings,
+ expectedWarnings,
+ `Got the expected warning for ${id}`
+ );
+ await ext.unload();
+ }
+
+ await testInvalidPermissionWarning({ isPrivileged: false });
+ await testInvalidPermissionWarning({ isPrivileged: true });
+
+ info("Test invalid permission warning on ExtensionData instance");
+ // Generate an extension (just to be able to reuse its rootURI for the
+ // ExtensionData instance created below).
+ let generatedExt = ExtensionTestCommon.generate({
+ manifest: {
+ permissions: ["mozillaAddons", "resource://x/", "about:reader*"],
+ browser_specific_settings: {
+ gecko: { id: "extension-data@mochi.test" },
+ },
+ },
+ });
+
+ // Verify that XPIInstall.jsm will not collect the warning for the
+ // privileged permission as expected.
+ async function getWarningsFromExtensionData({ isPrivileged }) {
+ let extData;
+ if (typeof isPrivileged == "function") {
+ // isPrivileged expected to be computed asynchronously.
+ extData = await ExtensionData.constructAsync({
+ rootURI: generatedExt.rootURI,
+ checkPrivileged: isPrivileged,
+ });
+ } else {
+ extData = new ExtensionData(generatedExt.rootURI, isPrivileged);
+ }
+ await extData.loadManifest();
+
+ // This assertion is just meant to prevent the test to pass if there were
+ // no warnings because some errors prevented the warnings to be
+ // collected).
+ Assert.deepEqual(
+ extData.errors,
+ [],
+ "No errors collected by the ExtensionData instance"
+ );
+ return extData.warnings;
+ }
+
+ Assert.deepEqual(
+ await getWarningsFromExtensionData({ isPrivileged: undefined }),
+ MANIFEST_WARNINGS,
+ "Got warnings about privileged permissions by default"
+ );
+
+ Assert.deepEqual(
+ await getWarningsFromExtensionData({ isPrivileged: false }),
+ MANIFEST_WARNINGS,
+ "Got warnings about privileged permissions for non-privileged extensions"
+ );
+
+ Assert.deepEqual(
+ await getWarningsFromExtensionData({ isPrivileged: true }),
+ [],
+ "No warnings about privileged permissions on privileged extensions"
+ );
+
+ Assert.deepEqual(
+ await getWarningsFromExtensionData({ isPrivileged: async () => false }),
+ MANIFEST_WARNINGS,
+ "Got warnings about privileged permissions for non-privileged extensions (async)"
+ );
+
+ Assert.deepEqual(
+ await getWarningsFromExtensionData({ isPrivileged: async () => true }),
+ [],
+ "No warnings about privileged permissions on privileged extensions (async)"
+ );
+
+ // Cleanup the generated xpi file.
+ await generatedExt.cleanupGeneratedFile();
+
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js b/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js
new file mode 100644
index 0000000000..88afd36dcc
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js
@@ -0,0 +1,240 @@
+"use strict";
+
+// This file tests the behavior of fetch/XMLHttpRequest in content scripts, in
+// relation to permissions, in MV2.
+// In MV3, the expectations are different, test coverage for that is in
+// test_ext_xhr_cors.js (along with CORS tests that also apply to MV2).
+
+const server = createHttpServer({
+ hosts: ["xpcshell.test", "example.com", "example.org"],
+});
+server.registerDirectory("/data/", do_get_file("data"));
+
+server.registerPathHandler("/example.txt", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+server.registerPathHandler("/return_headers.sjs", (request, response) => {
+ response.setHeader("Content-Type", "text/plain", false);
+
+ let headers = {};
+ for (let { data: header } of request.headers) {
+ headers[header.toLowerCase()] = request.getHeader(header);
+ }
+
+ response.write(JSON.stringify(headers));
+});
+
+/* eslint-disable mozilla/balanced-listeners */
+
+add_task(async function test_simple() {
+ async function runTests(cx) {
+ function xhr(XMLHttpRequest) {
+ return url => {
+ return new Promise((resolve, reject) => {
+ let req = new XMLHttpRequest();
+ req.open("GET", url);
+ req.addEventListener("load", resolve);
+ req.addEventListener("error", reject);
+ req.send();
+ });
+ };
+ }
+
+ function run(shouldFail, fetch) {
+ function passListener() {
+ browser.test.succeed(`${cx}.${fetch.name} pass listener`);
+ }
+
+ function failListener() {
+ browser.test.fail(`${cx}.${fetch.name} fail listener`);
+ }
+
+ /* eslint-disable no-else-return */
+ if (shouldFail) {
+ return fetch("http://example.org/example.txt").then(
+ failListener,
+ passListener
+ );
+ } else {
+ return fetch("http://example.com/example.txt").then(
+ passListener,
+ failListener
+ );
+ }
+ /* eslint-enable no-else-return */
+ }
+
+ try {
+ await run(true, xhr(XMLHttpRequest));
+ await run(false, xhr(XMLHttpRequest));
+ await run(true, xhr(window.XMLHttpRequest));
+ await run(false, xhr(window.XMLHttpRequest));
+ await run(true, fetch);
+ await run(false, fetch);
+ await run(true, window.fetch);
+ await run(false, window.fetch);
+ } catch (err) {
+ browser.test.fail(`Error: ${err} :: ${err.stack}`);
+ browser.test.notifyFail("permission_xhr");
+ }
+ }
+
+ async function background(runTestsFn) {
+ await runTestsFn("bg");
+ browser.test.notifyPass("permission_xhr");
+ }
+
+ let extensionData = {
+ background: `(${background})(${runTests})`,
+ manifest: {
+ permissions: ["http://example.com/"],
+ content_scripts: [
+ {
+ matches: ["http://xpcshell.test/data/file_permission_xhr.html"],
+ js: ["content.js"],
+ },
+ ],
+ },
+ files: {
+ "content.js": `(${async runTestsFn => {
+ await runTestsFn("content");
+
+ window.wrappedJSObject.privilegedFetch = fetch;
+ window.wrappedJSObject.privilegedXHR = XMLHttpRequest;
+
+ window.addEventListener("message", function rcv({ data }) {
+ switch (data.msg) {
+ case "test":
+ break;
+
+ case "assertTrue":
+ browser.test.assertTrue(data.condition, data.description);
+ break;
+
+ case "finish":
+ window.removeEventListener("message", rcv);
+ browser.test.sendMessage("content-script-finished");
+ break;
+ }
+ });
+ window.postMessage("test", "*");
+ }})(${runTests})`,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://xpcshell.test/data/file_permission_xhr.html"
+ );
+ await extension.awaitMessage("content-script-finished");
+ await contentPage.close();
+
+ await extension.awaitFinish("permission_xhr");
+ await extension.unload();
+});
+
+// This test case ensures that a WebExtension content script can still use the same
+// XMLHttpRequest and fetch APIs that the webpage can use and be recognized from
+// the target server with the same origin and referer headers of the target webpage
+// (see Bug 1295660 for a rationale).
+add_task(async function test_page_xhr() {
+ async function contentScript() {
+ const content = this.content;
+
+ const { webpageFetchResult, webpageXhrResult } = await new Promise(
+ resolve => {
+ const listenPageMessage = event => {
+ if (!event.data || event.data.type !== "testPageGlobals") {
+ return;
+ }
+
+ window.removeEventListener("message", listenPageMessage);
+
+ browser.test.assertEq(
+ true,
+ !!content.XMLHttpRequest,
+ "The content script should have access to content.XMLHTTPRequest"
+ );
+ browser.test.assertEq(
+ true,
+ !!content.fetch,
+ "The content script should have access to window.pageFetch"
+ );
+
+ resolve(event.data);
+ };
+
+ window.addEventListener("message", listenPageMessage);
+
+ window.postMessage({}, "*");
+ }
+ );
+
+ const url = new URL("/return_headers.sjs", location).href;
+
+ await Promise.all([
+ new Promise((resolve, reject) => {
+ const req = new content.XMLHttpRequest();
+ req.open("GET", url);
+ req.addEventListener("load", () =>
+ resolve(JSON.parse(req.responseText))
+ );
+ req.addEventListener("error", reject);
+ req.send();
+ }),
+ content.fetch(url).then(res => res.json()),
+ ])
+ .then(async ([xhrResult, fetchResult]) => {
+ browser.test.assertEq(
+ webpageFetchResult.referer,
+ fetchResult.referer,
+ "window.pageFetch referrer is the same of a webpage fetch request"
+ );
+ browser.test.assertEq(
+ webpageFetchResult.origin,
+ fetchResult.origin,
+ "window.pageFetch origin is the same of a webpage fetch request"
+ );
+
+ browser.test.assertEq(
+ webpageXhrResult.referer,
+ xhrResult.referer,
+ "content.XMLHttpRequest referrer is the same of a webpage fetch request"
+ );
+ })
+ .catch(error => {
+ browser.test.fail(`Unexpected error: ${error}`);
+ });
+
+ browser.test.notifyPass("content-script-page-xhr");
+ }
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://xpcshell.test/*"],
+ js: ["content.js"],
+ },
+ ],
+ },
+ files: {
+ "content.js": `(${contentScript})()`,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://xpcshell.test/data/file_page_xhr.html"
+ );
+ await extension.awaitFinish("content-script-page-xhr");
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js
new file mode 100644
index 0000000000..68253c03e9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js
@@ -0,0 +1,1035 @@
+"use strict";
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const { permissionToL10nId } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissionMessages.sys.mjs"
+);
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+// ExtensionParent.jsm is being imported lazily because when it is imported Services.appinfo will be
+// retrieved and cached (as a side-effect of Schemas.jsm being imported), and so Services.appinfo
+// will not be returning the version set by AddonTestUtils.createAppInfo and this test will
+// fail on non-nightly builds (because the cached appinfo.version will be undefined and
+// AddonManager startup will fail).
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+});
+
+const l10n = new Localization([
+ "toolkit/global/extensions.ftl",
+ "toolkit/global/extensionPermissions.ftl",
+ "branding/brand.ftl",
+]);
+// Localization resources need to be first iterated outside a test
+l10n.formatValue("webext-perms-sideload-text");
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_setup(async () => {
+ // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy
+ // storage mode will run in xpcshell-legacy-ep.ini
+ await ExtensionPermissions._uninit();
+
+ optionalPermissionsPromptHandler.init();
+
+ await AddonTestUtils.promiseStartupManager();
+ AddonTestUtils.usePrivilegedSignatures = false;
+});
+
+add_task(async function test_permissions_on_startup() {
+ let extensionId = "@permissionTest";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: extensionId },
+ },
+ permissions: ["tabs"],
+ },
+ useAddonManager: "permanent",
+ async background() {
+ let perms = await browser.permissions.getAll();
+ browser.test.sendMessage("permissions", perms);
+ },
+ });
+ let adding = {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+ };
+ await extension.startup();
+ let perms = await extension.awaitMessage("permissions");
+ equal(perms.permissions.length, 1, "one permission");
+ equal(perms.permissions[0], "tabs", "internal permission not present");
+
+ const { StartupCache } = ExtensionParent;
+
+ // StartupCache.permissions will not contain the extension permissions.
+ let manifestData = await StartupCache.permissions.get(extensionId, () => {
+ return { permissions: [], origins: [] };
+ });
+ equal(manifestData.permissions.length, 0, "no permission");
+
+ perms = await ExtensionPermissions.get(extensionId);
+ equal(perms.permissions.length, 0, "no permissions");
+ await ExtensionPermissions.add(extensionId, adding);
+
+ // Restart the extension and re-test the permissions.
+ await ExtensionPermissions._uninit();
+ await AddonTestUtils.promiseRestartManager();
+ let restarted = extension.awaitMessage("permissions");
+ await extension.awaitStartup();
+ perms = await restarted;
+
+ manifestData = await StartupCache.permissions.get(extensionId, () => {
+ return { permissions: [], origins: [] };
+ });
+ deepEqual(
+ manifestData.permissions,
+ adding.permissions,
+ "StartupCache.permissions contains permission"
+ );
+
+ equal(perms.permissions.length, 1, "one permission");
+ equal(perms.permissions[0], "tabs", "internal permission not present");
+ let added = await ExtensionPermissions._get(extensionId);
+ deepEqual(added, adding, "permissions were retained");
+
+ await extension.unload();
+});
+
+async function test_permissions({
+ manifest_version,
+ granted_host_permissions,
+ useAddonManager,
+ expectAllGranted,
+}) {
+ const REQUIRED_PERMISSIONS = ["downloads"];
+ const REQUIRED_ORIGINS = ["*://site.com/", "*://*.domain.com/"];
+ const REQUIRED_ORIGINS_EXPECTED = expectAllGranted
+ ? ["*://site.com/*", "*://*.domain.com/*"]
+ : [];
+
+ const OPTIONAL_PERMISSIONS = ["idle", "clipboardWrite"];
+ const OPTIONAL_ORIGINS = [
+ "http://optionalsite.com/",
+ "https://*.optionaldomain.com/",
+ ];
+ const OPTIONAL_ORIGINS_NORMALIZED = [
+ "http://optionalsite.com/*",
+ "https://*.optionaldomain.com/*",
+ ];
+
+ function background() {
+ browser.test.onMessage.addListener(async (method, arg) => {
+ if (method == "getAll") {
+ let perms = await browser.permissions.getAll();
+ let url = browser.runtime.getURL("*");
+ perms.origins = perms.origins.filter(i => i != url);
+ browser.test.sendMessage("getAll.result", perms);
+ } else if (method == "contains") {
+ let result = await browser.permissions.contains(arg);
+ browser.test.sendMessage("contains.result", result);
+ } else if (method == "request") {
+ try {
+ let result = await browser.permissions.request(arg);
+ browser.test.sendMessage("request.result", {
+ status: "success",
+ result,
+ });
+ } catch (err) {
+ browser.test.sendMessage("request.result", {
+ status: "error",
+ message: err.message,
+ });
+ }
+ } else if (method == "remove") {
+ let result = await browser.permissions.remove(arg);
+ browser.test.sendMessage("remove.result", result);
+ }
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ manifest_version,
+ permissions: REQUIRED_PERMISSIONS,
+ host_permissions: REQUIRED_ORIGINS,
+ optional_permissions: [...OPTIONAL_PERMISSIONS, ...OPTIONAL_ORIGINS],
+ granted_host_permissions,
+ },
+ useAddonManager,
+ });
+
+ await extension.startup();
+
+ function call(method, arg) {
+ extension.sendMessage(method, arg);
+ return extension.awaitMessage(`${method}.result`);
+ }
+
+ let result = await call("getAll");
+ deepEqual(result.permissions, REQUIRED_PERMISSIONS);
+ deepEqual(result.origins, REQUIRED_ORIGINS_EXPECTED);
+
+ for (let perm of REQUIRED_PERMISSIONS) {
+ result = await call("contains", { permissions: [perm] });
+ equal(result, true, `contains() returns true for fixed permission ${perm}`);
+ }
+ for (let origin of REQUIRED_ORIGINS) {
+ result = await call("contains", { origins: [origin] });
+ equal(
+ result,
+ expectAllGranted,
+ `contains() returns true for fixed origin ${origin}`
+ );
+ }
+
+ // None of the optional permissions should be available yet
+ for (let perm of OPTIONAL_PERMISSIONS) {
+ result = await call("contains", { permissions: [perm] });
+ equal(result, false, `contains() returns false for permission ${perm}`);
+ }
+ for (let origin of OPTIONAL_ORIGINS) {
+ result = await call("contains", { origins: [origin] });
+ equal(result, false, `contains() returns false for origin ${origin}`);
+ }
+
+ result = await call("contains", {
+ permissions: [...REQUIRED_PERMISSIONS, ...OPTIONAL_PERMISSIONS],
+ });
+ equal(
+ result,
+ false,
+ "contains() returns false for a mix of available and unavailable permissions"
+ );
+
+ let perm = OPTIONAL_PERMISSIONS[0];
+ result = await call("request", { permissions: [perm] });
+ equal(
+ result.status,
+ "error",
+ "request() fails if not called from an event handler"
+ );
+ ok(
+ /request may only be called from a user input handler/.test(result.message),
+ "error message for calling request() outside an event handler is reasonable"
+ );
+ result = await call("contains", { permissions: [perm] });
+ equal(
+ result,
+ false,
+ "Permission requested outside an event handler was not granted"
+ );
+
+ await withHandlingUserInput(extension, async () => {
+ result = await call("request", { permissions: ["notifications"] });
+ equal(
+ result.status,
+ "error",
+ "request() for permission not in optional_permissions should fail"
+ );
+ ok(
+ /since it was not declared in optional_permissions/.test(result.message),
+ "error message for undeclared optional_permission is reasonable"
+ );
+
+ // Check request() when the prompt is canceled.
+ optionalPermissionsPromptHandler.acceptPrompt = false;
+ result = await call("request", { permissions: [perm] });
+ equal(result.status, "success", "request() returned cleanly");
+ equal(
+ result.result,
+ false,
+ "request() returned false for rejected permission"
+ );
+
+ result = await call("contains", { permissions: [perm] });
+ equal(result, false, "Rejected permission was not granted");
+
+ // Call request() and accept the prompt
+ optionalPermissionsPromptHandler.acceptPrompt = true;
+ let allOptional = {
+ permissions: OPTIONAL_PERMISSIONS,
+ origins: OPTIONAL_ORIGINS,
+ };
+ result = await call("request", allOptional);
+ equal(result.status, "success", "request() returned cleanly");
+ equal(
+ result.result,
+ true,
+ "request() returned true for accepted permissions"
+ );
+
+ // Verify that requesting a permission/origin in the wrong field fails
+ let originsAsPerms = {
+ permissions: OPTIONAL_ORIGINS,
+ };
+ let permsAsOrigins = {
+ origins: OPTIONAL_PERMISSIONS,
+ };
+
+ result = await call("request", originsAsPerms);
+ equal(
+ result.status,
+ "error",
+ "Requesting an origin as a permission should fail"
+ );
+ ok(
+ /Type error for parameter permissions \(Error processing permissions/.test(
+ result.message
+ ),
+ "Error message for origin as permission is reasonable"
+ );
+
+ result = await call("request", permsAsOrigins);
+ equal(
+ result.status,
+ "error",
+ "Requesting a permission as an origin should fail"
+ );
+ ok(
+ /Type error for parameter permissions \(Error processing origins/.test(
+ result.message
+ ),
+ "Error message for permission as origin is reasonable"
+ );
+ });
+
+ let allPermissions = {
+ permissions: [...REQUIRED_PERMISSIONS, ...OPTIONAL_PERMISSIONS],
+ origins: [...REQUIRED_ORIGINS_EXPECTED, ...OPTIONAL_ORIGINS_NORMALIZED],
+ };
+
+ result = await call("getAll");
+ deepEqual(
+ result,
+ allPermissions,
+ "getAll() returns required and runtime requested permissions"
+ );
+
+ result = await call("contains", allPermissions);
+ equal(
+ result,
+ true,
+ "contains() returns true for runtime requested permissions"
+ );
+
+ async function restart() {
+ if (useAddonManager === "permanent") {
+ await AddonTestUtils.promiseRestartManager();
+ } else {
+ // Manually reload for temporarily loaded.
+ await extension.addon.reload();
+ }
+ await extension.awaitBackgroundStarted();
+ }
+
+ // Restart extension, verify permissions are still present.
+ await restart();
+
+ result = await call("getAll");
+ deepEqual(
+ result,
+ allPermissions,
+ "Runtime requested permissions are still present after restart"
+ );
+
+ // Check remove()
+ result = await call("remove", { permissions: OPTIONAL_PERMISSIONS });
+ equal(result, true, "remove() succeeded");
+
+ let perms = {
+ permissions: REQUIRED_PERMISSIONS,
+ origins: [...REQUIRED_ORIGINS_EXPECTED, ...OPTIONAL_ORIGINS_NORMALIZED],
+ };
+
+ result = await call("getAll");
+ deepEqual(result, perms, "Expected permissions remain after removing some");
+
+ result = await call("remove", { origins: OPTIONAL_ORIGINS });
+ equal(result, true, "remove() succeeded");
+
+ perms.origins = REQUIRED_ORIGINS_EXPECTED;
+ result = await call("getAll");
+ deepEqual(result, perms, "Back to default permissions after removing more");
+
+ if (granted_host_permissions && expectAllGranted) {
+ // Check that all (granted) host permissions in MV3 can be revoked.
+
+ result = await call("remove", { origins: REQUIRED_ORIGINS });
+ equal(result, true, "remove() succeeded");
+ perms.origins = [];
+
+ result = await call("getAll");
+ deepEqual(
+ result,
+ perms,
+ "Expected only api permissions remain after removing all origins in mv3."
+ );
+ }
+
+ // Clear cache to confirm same result after rebuilding it (after an update).
+ await ExtensionParent.StartupCache.clearAddonData(extension.id);
+
+ // Restart again, verify optional permissions state is still preserved.
+ await restart();
+
+ result = await call("getAll");
+ deepEqual(result, perms, "Expected the same permissions after restart.");
+
+ await extension.unload();
+}
+
+add_task(function test_normal_mv2() {
+ return test_permissions({
+ manifest_version: 2,
+ useAddonManager: "permanent",
+ expectAllGranted: true,
+ });
+});
+
+add_task(function test_normal_mv3() {
+ return test_permissions({
+ manifest_version: 3,
+ useAddonManager: "permanent",
+ expectAllGranted: false,
+ });
+});
+
+add_task(function test_granted_for_temporary_mv3() {
+ return test_permissions({
+ manifest_version: 3,
+ granted_host_permissions: true,
+ useAddonManager: "temporary",
+ expectAllGranted: true,
+ });
+});
+
+add_task(async function test_granted_only_for_privileged_mv3() {
+ try {
+ // For permanent non-privileged, granted_host_permissions does nothing.
+ await test_permissions({
+ manifest_version: 3,
+ granted_host_permissions: true,
+ useAddonManager: "permanent",
+ expectAllGranted: false,
+ });
+
+ // Make extensions loaded with addon manager privileged.
+ AddonTestUtils.usePrivilegedSignatures = true;
+
+ await test_permissions({
+ manifest_version: 3,
+ granted_host_permissions: true,
+ useAddonManager: "permanent",
+ expectAllGranted: true,
+ });
+ } finally {
+ AddonTestUtils.usePrivilegedSignatures = false;
+ }
+});
+
+add_task(async function test_startup() {
+ async function background() {
+ browser.test.onMessage.addListener(async perms => {
+ await browser.permissions.request(perms);
+ browser.test.sendMessage("requested");
+ });
+
+ let all = await browser.permissions.getAll();
+ let url = browser.runtime.getURL("*");
+ all.origins = all.origins.filter(i => i != url);
+ browser.test.sendMessage("perms", all);
+ }
+
+ const PERMS1 = {
+ permissions: ["clipboardRead", "tabs"],
+ };
+ const PERMS2 = {
+ origins: ["https://site2.com/*"],
+ };
+
+ let extension1 = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ optional_permissions: PERMS1.permissions,
+ },
+ useAddonManager: "permanent",
+ });
+ let extension2 = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ optional_permissions: PERMS2.origins,
+ },
+ useAddonManager: "permanent",
+ });
+
+ await extension1.startup();
+ await extension2.startup();
+
+ let perms = await extension1.awaitMessage("perms");
+ perms = await extension2.awaitMessage("perms");
+
+ await withHandlingUserInput(extension1, async () => {
+ extension1.sendMessage(PERMS1);
+ await extension1.awaitMessage("requested");
+ });
+
+ await withHandlingUserInput(extension2, async () => {
+ extension2.sendMessage(PERMS2);
+ await extension2.awaitMessage("requested");
+ });
+
+ // Restart everything, and force the permissions store to be
+ // re-read on startup
+ await ExtensionPermissions._uninit();
+ await AddonTestUtils.promiseRestartManager();
+ await extension1.awaitStartup();
+ await extension2.awaitStartup();
+
+ async function checkPermissions(extension, permissions) {
+ perms = await extension.awaitMessage("perms");
+ let expect = Object.assign({ permissions: [], origins: [] }, permissions);
+ deepEqual(perms, expect, "Extension got correct permissions on startup");
+ }
+
+ await checkPermissions(extension1, PERMS1);
+ await checkPermissions(extension2, PERMS2);
+
+ await extension1.unload();
+ await extension2.unload();
+});
+
+// Test that we don't prompt for permissions an extension already has.
+async function test_alreadyGranted(manifest_version) {
+ const REQUIRED_PERMISSIONS = ["geolocation"];
+ const REQUIRED_ORIGINS = [
+ "*://required-host.com/",
+ "*://*.required-domain.com/",
+ ];
+ const OPTIONAL_PERMISSIONS = [
+ ...REQUIRED_PERMISSIONS,
+ ...REQUIRED_ORIGINS,
+ "clipboardRead",
+ "*://optional-host.com/",
+ "*://*.optional-domain.com/",
+ ];
+
+ function pageScript() {
+ browser.test.onMessage.addListener(async (msg, arg) => {
+ if (msg == "request") {
+ let result = await browser.permissions.request(arg);
+ browser.test.sendMessage("request.result", result);
+ } else if (msg == "remove") {
+ let result = await browser.permissions.remove(arg);
+ browser.test.sendMessage("remove.result", result);
+ } else if (msg == "close") {
+ window.close();
+ }
+ });
+
+ browser.test.sendMessage("page-ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+
+ manifest: {
+ manifest_version,
+ permissions: REQUIRED_PERMISSIONS,
+ host_permissions: REQUIRED_ORIGINS,
+ optional_permissions: OPTIONAL_PERMISSIONS,
+ granted_host_permissions: true,
+ },
+ temporarilyInstalled: true,
+ startupReason: "ADDON_INSTALL",
+ files: {
+ "page.html": `<html><head>
+ <script src="page.js"><\/script>
+ </head></html>`,
+
+ "page.js": pageScript,
+ },
+ });
+
+ await extension.startup();
+
+ await withHandlingUserInput(extension, async () => {
+ let url = await extension.awaitMessage("ready");
+ let page = await ExtensionTestUtils.loadContentPage(url, { extension });
+ await extension.awaitMessage("page-ready");
+
+ async function checkRequest(arg, expectPrompt, msg) {
+ optionalPermissionsPromptHandler.sawPrompt = false;
+ extension.sendMessage("request", arg);
+ let result = await extension.awaitMessage("request.result");
+ ok(result, "request() call succeeded");
+ equal(
+ optionalPermissionsPromptHandler.sawPrompt,
+ expectPrompt,
+ `Got ${expectPrompt ? "" : "no "}permission prompt for ${msg}`
+ );
+ }
+
+ await checkRequest(
+ { permissions: ["geolocation"] },
+ false,
+ "required permission from manifest"
+ );
+ await checkRequest(
+ { origins: ["http://required-host.com/"] },
+ false,
+ "origin permission from manifest"
+ );
+ await checkRequest(
+ { origins: ["http://host.required-domain.com/"] },
+ false,
+ "wildcard origin permission from manifest"
+ );
+
+ await checkRequest(
+ { permissions: ["clipboardRead"] },
+ true,
+ "optional permission"
+ );
+ await checkRequest(
+ { permissions: ["clipboardRead"] },
+ false,
+ "already granted optional permission"
+ );
+
+ await checkRequest(
+ { origins: ["http://optional-host.com/"] },
+ true,
+ "optional origin"
+ );
+ await checkRequest(
+ { origins: ["http://optional-host.com/"] },
+ false,
+ "already granted origin permission"
+ );
+
+ await checkRequest(
+ { origins: ["http://*.optional-domain.com/"] },
+ true,
+ "optional wildcard origin"
+ );
+ await checkRequest(
+ { origins: ["http://*.optional-domain.com/"] },
+ false,
+ "already granted optional wildcard origin"
+ );
+ await checkRequest(
+ { origins: ["http://host.optional-domain.com/"] },
+ false,
+ "host matching optional wildcard origin"
+ );
+ await page.close();
+ });
+
+ await extension.unload();
+}
+add_task(async function test_alreadyGranted_mv2() {
+ return test_alreadyGranted(2);
+});
+add_task(async function test_alreadyGranted_mv3() {
+ return test_alreadyGranted(3);
+});
+
+// IMPORTANT: Do not change this list without review from a Web Extensions peer!
+
+const GRANTED_WITHOUT_USER_PROMPT = [
+ "activeTab",
+ "activityLog",
+ "alarms",
+ "captivePortal",
+ "contextMenus",
+ "contextualIdentities",
+ "cookies",
+ "declarativeNetRequestWithHostAccess",
+ "dns",
+ "geckoProfiler",
+ "identity",
+ "idle",
+ "menus",
+ "menus.overrideContext",
+ "mozillaAddons",
+ "networkStatus",
+ "normandyAddonStudy",
+ "scripting",
+ "search",
+ "storage",
+ "telemetry",
+ "theme",
+ "unlimitedStorage",
+ "urlbar",
+ "webRequest",
+ "webRequestBlocking",
+ "webRequestFilterResponse",
+ "webRequestFilterResponse.serviceWorkerScript",
+];
+
+add_task(async function test_permissions_have_localization_strings() {
+ let noPromptNames = Schemas.getPermissionNames([
+ "PermissionNoPrompt",
+ "OptionalPermissionNoPrompt",
+ "PermissionPrivileged",
+ ]);
+ Assert.deepEqual(
+ GRANTED_WITHOUT_USER_PROMPT,
+ noPromptNames,
+ "List of no-prompt permissions is correct."
+ );
+
+ for (const perm of Schemas.getPermissionNames()) {
+ const permId = permissionToL10nId(perm);
+ if (permId) {
+ const str = await l10n.formatValue(permId);
+ ok(str.length, `Found localization string for '${perm}' permission`);
+ } else {
+ ok(
+ GRANTED_WITHOUT_USER_PROMPT.includes(perm),
+ `Permission '${perm}' intentionally granted without prompting the user`
+ );
+ }
+ }
+});
+
+// Check <all_urls> used as an optional API permission.
+add_task(async function test_optional_all_urls() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ optional_permissions: ["<all_urls>"],
+ },
+
+ background() {
+ browser.test.onMessage.addListener(async () => {
+ let before = !!browser.tabs.captureVisibleTab;
+ let granted = await browser.permissions.request({
+ origins: ["<all_urls>"],
+ });
+ let after = !!browser.tabs.captureVisibleTab;
+
+ browser.test.sendMessage("results", [before, granted, after]);
+ });
+ },
+ });
+
+ await extension.startup();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request");
+ let [before, granted, after] = await extension.awaitMessage("results");
+
+ equal(
+ before,
+ false,
+ "captureVisibleTab() unavailable before optional permission request()"
+ );
+ equal(granted, true, "request() for optional permissions granted");
+ equal(
+ after,
+ true,
+ "captureVisibleTab() available after optional permission request()"
+ );
+ });
+
+ await extension.unload();
+});
+
+// Check when content_script match patterns are treated as optional origins.
+async function test_content_script_is_optional(manifest_version) {
+ function background() {
+ browser.test.onMessage.addListener(async (msg, arg) => {
+ if (msg == "request") {
+ try {
+ let result = await browser.permissions.request(arg);
+ browser.test.sendMessage("result", result);
+ } catch (e) {
+ browser.test.sendMessage("result", e.message);
+ }
+ }
+ if (msg === "getAll") {
+ let result = await browser.permissions.getAll(arg);
+ browser.test.sendMessage("granted", result);
+ }
+ });
+ }
+
+ const CS_ORIGIN = "https://test2.example.com/*";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ manifest_version,
+ content_scripts: [
+ {
+ matches: [CS_ORIGIN],
+ js: [],
+ },
+ ],
+ },
+ });
+
+ await extension.startup();
+
+ extension.sendMessage("getAll");
+ let initial = await extension.awaitMessage("granted");
+ deepEqual(initial.origins, [], "Nothing granted on install.");
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request", {
+ permissions: [],
+ origins: [CS_ORIGIN],
+ });
+ let result = await extension.awaitMessage("result");
+ if (manifest_version < 3) {
+ equal(
+ result,
+ `Cannot request origin permission for ${CS_ORIGIN} since it was not declared in the manifest`,
+ "Content script match pattern is not a requestable optional origin in MV2"
+ );
+ } else {
+ equal(result, true, "request() for optional permissions succeeded");
+ }
+ });
+
+ extension.sendMessage("getAll");
+ let granted = await extension.awaitMessage("granted");
+ deepEqual(
+ granted.origins,
+ manifest_version < 3 ? [] : [CS_ORIGIN],
+ "Granted content script origin in MV3."
+ );
+
+ await extension.unload();
+}
+add_task(() => test_content_script_is_optional(2));
+add_task(() => test_content_script_is_optional(3));
+
+// Check that optional permissions are not included in update prompts
+async function test_permissions_prompt(manifest_version) {
+ function background() {
+ browser.test.onMessage.addListener(async (msg, arg) => {
+ if (msg == "request") {
+ let result = await browser.permissions.request(arg);
+ browser.test.sendMessage("result", result);
+ }
+ if (msg === "getAll") {
+ let result = await browser.permissions.getAll(arg);
+ browser.test.sendMessage("granted", result);
+ }
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ name: "permissions test",
+ description: "permissions test",
+ manifest_version,
+ version: "1.0",
+
+ permissions: ["tabs"],
+ host_permissions: ["https://test1.example.com/*"],
+ optional_permissions: ["clipboardWrite", "<all_urls>"],
+
+ content_scripts: [
+ {
+ matches: ["https://test2.example.com/*"],
+ js: [],
+ },
+ ],
+ },
+ useAddonManager: "permanent",
+ });
+
+ await extension.startup();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request", {
+ permissions: ["clipboardWrite"],
+ origins: ["https://test2.example.com/*"],
+ });
+ let result = await extension.awaitMessage("result");
+ equal(result, true, "request() for optional permissions succeeded");
+ });
+
+ if (manifest_version >= 3) {
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request", {
+ origins: ["https://test1.example.com/*"],
+ });
+ let result = await extension.awaitMessage("result");
+ equal(result, true, "request() for host_permissions in mv3 succeeded");
+ });
+ }
+
+ const PERMS = ["history", "tabs"];
+ const ORIGINS = ["https://test1.example.com/*", "https://test3.example.com/"];
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ background,
+ manifest: {
+ name: "permissions test",
+ description: "permissions test",
+ manifest_version,
+ version: "2.0",
+
+ browser_specific_settings: { gecko: { id: extension.id } },
+
+ permissions: PERMS,
+ host_permissions: ORIGINS,
+ optional_permissions: ["clipboardWrite", "<all_urls>"],
+ },
+ });
+
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ let perminfo;
+ install.promptHandler = info => {
+ perminfo = info;
+ return Promise.resolve();
+ };
+
+ await AddonTestUtils.promiseCompleteInstall(install);
+ await extension.awaitStartup();
+
+ notEqual(perminfo, undefined, "Permission handler was invoked");
+ let perms = perminfo.addon.userPermissions;
+ deepEqual(
+ perms.permissions,
+ PERMS,
+ "Update details includes only manifest api permissions"
+ );
+ deepEqual(
+ perms.origins,
+ manifest_version < 3 ? ORIGINS : [],
+ "Update details includes only manifest origin permissions"
+ );
+
+ let EXPECTED = ["https://test1.example.com/*", "https://test2.example.com/*"];
+ if (manifest_version < 3) {
+ EXPECTED.push("https://test3.example.com/*");
+ }
+
+ extension.sendMessage("getAll");
+ let granted = await extension.awaitMessage("granted");
+ deepEqual(
+ granted.origins.sort(),
+ EXPECTED,
+ "Granted origins persisted after update."
+ );
+
+ await extension.unload();
+}
+add_task(async function test_permissions_prompt_mv2() {
+ return test_permissions_prompt(2);
+});
+add_task(async function test_permissions_prompt_mv3() {
+ return test_permissions_prompt(3);
+});
+
+// Check that internal permissions can not be set and are not returned by the API.
+add_task(async function test_internal_permissions() {
+ function background() {
+ browser.test.onMessage.addListener(async (method, arg) => {
+ try {
+ if (method == "getAll") {
+ let perms = await browser.permissions.getAll();
+ browser.test.sendMessage("getAll.result", perms);
+ } else if (method == "contains") {
+ let result = await browser.permissions.contains(arg);
+ browser.test.sendMessage("contains.result", {
+ status: "success",
+ result,
+ });
+ } else if (method == "request") {
+ let result = await browser.permissions.request(arg);
+ browser.test.sendMessage("request.result", {
+ status: "success",
+ result,
+ });
+ } else if (method == "remove") {
+ let result = await browser.permissions.remove(arg);
+ browser.test.sendMessage("remove.result", result);
+ }
+ } catch (err) {
+ browser.test.sendMessage(`${method}.result`, {
+ status: "error",
+ message: err.message,
+ });
+ }
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ name: "permissions test",
+ description: "permissions test",
+ manifest_version: 2,
+ version: "1.0",
+ permissions: [],
+ },
+ useAddonManager: "permanent",
+ incognitoOverride: "spanning",
+ });
+
+ let perm = "internal:privateBrowsingAllowed";
+
+ await extension.startup();
+
+ function call(method, arg) {
+ extension.sendMessage(method, arg);
+ return extension.awaitMessage(`${method}.result`);
+ }
+
+ let result = await call("getAll");
+ ok(!result.permissions.includes(perm), "internal not returned");
+
+ result = await call("contains", { permissions: [perm] });
+ ok(
+ /Type error for parameter permissions \(Error processing permissions/.test(
+ result.message
+ ),
+ `Unable to check for internal permission: ${result.message}`
+ );
+
+ result = await call("remove", { permissions: [perm] });
+ ok(
+ /Type error for parameter permissions \(Error processing permissions/.test(
+ result.message
+ ),
+ `Unable to remove for internal permission ${result.message}`
+ );
+
+ await withHandlingUserInput(extension, async () => {
+ result = await call("request", {
+ permissions: [perm],
+ origins: [],
+ });
+ ok(
+ /Type error for parameter permissions \(Error processing permissions/.test(
+ result.message
+ ),
+ `Unable to request internal permission ${result.message}`
+ );
+ });
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js
new file mode 100644
index 0000000000..526f0a720e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js
@@ -0,0 +1,464 @@
+"use strict";
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+let OptionalPermissions;
+
+add_task(async function setup() {
+ // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy
+ // storage mode will run in xpcshell-legacy-ep.ini
+ await ExtensionPermissions._uninit();
+
+ Services.prefs.setBoolPref(
+ "extensions.webextOptionalPermissionPrompts",
+ false
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts");
+ });
+ await AddonTestUtils.promiseStartupManager();
+ AddonTestUtils.usePrivilegedSignatures = false;
+
+ // We want to get a list of optional permissions prior to loading an extension,
+ // so we'll get ExtensionParent to do that for us.
+ await ExtensionParent.apiManager.lazyInit();
+
+ // These permissions have special behaviors and/or are not mapped directly to an
+ // api namespace. They will have their own tests for specific behavior.
+ let ignore = [
+ "activeTab",
+ "clipboardRead",
+ "clipboardWrite",
+ "declarativeNetRequestFeedback",
+ "devtools",
+ "downloads.open",
+ "geolocation",
+ "management",
+ "menus.overrideContext",
+ "nativeMessaging",
+ "scripting",
+ "search",
+ "tabHide",
+ "tabs",
+ "webRequestBlocking",
+ "webRequestFilterResponse",
+ "webRequestFilterResponse.serviceWorkerScript",
+ ];
+ OptionalPermissions = Schemas.getPermissionNames([
+ "OptionalPermission",
+ "OptionalPermissionNoPrompt",
+ ]).filter(n => !ignore.includes(n));
+});
+
+add_task(async function test_api_on_permissions_changed() {
+ async function background() {
+ let manifest = browser.runtime.getManifest();
+ let permObj = { permissions: manifest.optional_permissions, origins: [] };
+
+ function verifyPermissions(enabled) {
+ for (let perm of manifest.optional_permissions) {
+ browser.test.assertEq(
+ enabled,
+ !!browser[perm],
+ `${perm} API is ${
+ enabled ? "injected" : "removed"
+ } after permission request`
+ );
+ }
+ }
+
+ browser.permissions.onAdded.addListener(details => {
+ browser.test.assertEq(
+ JSON.stringify(details.permissions),
+ JSON.stringify(manifest.optional_permissions),
+ "expected permissions added"
+ );
+ verifyPermissions(true);
+ browser.test.sendMessage("added");
+ });
+
+ browser.permissions.onRemoved.addListener(details => {
+ browser.test.assertEq(
+ JSON.stringify(details.permissions),
+ JSON.stringify(manifest.optional_permissions),
+ "expected permissions removed"
+ );
+ verifyPermissions(false);
+ browser.test.sendMessage("removed");
+ });
+
+ browser.test.onMessage.addListener((msg, enabled) => {
+ if (msg === "request") {
+ browser.permissions.request(permObj);
+ } else if (msg === "verify_access") {
+ verifyPermissions(enabled);
+ browser.test.sendMessage("verified");
+ } else if (msg === "revoke") {
+ browser.permissions.remove(permObj);
+ }
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ optional_permissions: OptionalPermissions,
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ function addPermissions() {
+ extension.sendMessage("request");
+ return extension.awaitMessage("added");
+ }
+
+ function removePermissions() {
+ extension.sendMessage("revoke");
+ return extension.awaitMessage("removed");
+ }
+
+ function verifyPermissions(enabled) {
+ extension.sendMessage("verify_access", enabled);
+ return extension.awaitMessage("verified");
+ }
+
+ await withHandlingUserInput(extension, async () => {
+ await addPermissions();
+ await removePermissions();
+ await addPermissions();
+ });
+
+ // reset handlingUserInput for the restart
+ extensionHandlers.delete(extension);
+
+ // Verify access on restart
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitBackgroundStarted();
+ await verifyPermissions(true);
+
+ await withHandlingUserInput(extension, async () => {
+ await removePermissions();
+ });
+
+ // Add private browsing to be sure it doesn't come through.
+ let permObj = {
+ permissions: OptionalPermissions.concat("internal:privateBrowsingAllowed"),
+ origins: [],
+ };
+
+ // enable the permissions while the addon is running
+ await ExtensionPermissions.add(extension.id, permObj, extension.extension);
+ await extension.awaitMessage("added");
+ await verifyPermissions(true);
+
+ // disable the permissions while the addon is running
+ await ExtensionPermissions.remove(extension.id, permObj, extension.extension);
+ await extension.awaitMessage("removed");
+ await verifyPermissions(false);
+
+ // Add private browsing to test internal permission. If it slips through,
+ // we would get an error for an additional added message.
+ await ExtensionPermissions.add(
+ extension.id,
+ { permissions: ["internal:privateBrowsingAllowed"], origins: [] },
+ extension.extension
+ );
+
+ // disable the addon and re-test revoking permissions.
+ await withHandlingUserInput(extension, async () => {
+ await addPermissions();
+ });
+ let addon = await AddonManager.getAddonByID(extension.id);
+ await addon.disable();
+ await ExtensionPermissions.remove(extension.id, permObj);
+ await addon.enable();
+ await extension.awaitStartup();
+
+ await verifyPermissions(false);
+ let perms = await ExtensionPermissions.get(extension.id);
+ equal(perms.permissions.length, 0, "no permissions on startup");
+
+ await extension.unload();
+});
+
+add_task(async function test_geo_permissions() {
+ async function background() {
+ const permObj = { permissions: ["geolocation"] };
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "request") {
+ await browser.permissions.request(permObj);
+ } else if (msg === "remove") {
+ await browser.permissions.remove(permObj);
+ }
+ let result = await browser.permissions.contains(permObj);
+ browser.test.sendMessage("done", result);
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ browser_specific_settings: { gecko: { id: "geo-test@test" } },
+ optional_permissions: ["geolocation"],
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ let policy = WebExtensionPolicy.getByID(extension.id);
+ let principal = policy.extension.principal;
+ equal(
+ Services.perms.testPermissionFromPrincipal(principal, "geo"),
+ Services.perms.UNKNOWN_ACTION,
+ "geolocation not allowed on install"
+ );
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request");
+ ok(await extension.awaitMessage("done"), "permission granted");
+ equal(
+ Services.perms.testPermissionFromPrincipal(principal, "geo"),
+ Services.perms.ALLOW_ACTION,
+ "geolocation allowed after requested"
+ );
+
+ extension.sendMessage("remove");
+ ok(!(await extension.awaitMessage("done")), "permission revoked");
+
+ equal(
+ Services.perms.testPermissionFromPrincipal(principal, "geo"),
+ Services.perms.UNKNOWN_ACTION,
+ "geolocation not allowed after removed"
+ );
+
+ // re-grant to test update removal
+ extension.sendMessage("request");
+ ok(await extension.awaitMessage("done"), "permission granted");
+ equal(
+ Services.perms.testPermissionFromPrincipal(principal, "geo"),
+ Services.perms.ALLOW_ACTION,
+ "geolocation allowed after re-requested"
+ );
+ });
+
+ // We should not have geo permission after this upgrade.
+ await extension.upgrade({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "geo-test@test" } },
+ },
+ useAddonManager: "permanent",
+ });
+
+ equal(
+ Services.perms.testPermissionFromPrincipal(principal, "geo"),
+ Services.perms.UNKNOWN_ACTION,
+ "geolocation not allowed after upgrade"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_browserSetting_permissions() {
+ async function background() {
+ const permObj = { permissions: ["browserSettings"] };
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "request") {
+ await browser.permissions.request(permObj);
+ await browser.browserSettings.cacheEnabled.set({ value: false });
+ } else if (msg === "remove") {
+ await browser.permissions.remove(permObj);
+ }
+ browser.test.sendMessage("done");
+ });
+ }
+
+ function cacheIsEnabled() {
+ return (
+ Services.prefs.getBoolPref("browser.cache.disk.enable") &&
+ Services.prefs.getBoolPref("browser.cache.memory.enable")
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ optional_permissions: ["browserSettings"],
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+ ok(cacheIsEnabled(), "setting is not set after startup");
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request");
+ await extension.awaitMessage("done");
+ ok(!cacheIsEnabled(), "setting was set after request");
+
+ extension.sendMessage("remove");
+ await extension.awaitMessage("done");
+ ok(cacheIsEnabled(), "setting is reset after remove");
+
+ extension.sendMessage("request");
+ await extension.awaitMessage("done");
+ ok(!cacheIsEnabled(), "setting was set after request");
+ });
+
+ await ExtensionPermissions._uninit();
+ extensionHandlers.delete(extension);
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitBackgroundStarted();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("remove");
+ await extension.awaitMessage("done");
+ ok(cacheIsEnabled(), "setting is reset after remove");
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_privacy_permissions() {
+ async function background() {
+ const permObj = { permissions: ["privacy"] };
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "request") {
+ await browser.permissions.request(permObj);
+ await browser.privacy.websites.trackingProtectionMode.set({
+ value: "always",
+ });
+ } else if (msg === "remove") {
+ await browser.permissions.remove(permObj);
+ }
+ browser.test.sendMessage("done");
+ });
+ }
+
+ function hasSetting() {
+ return Services.prefs.getBoolPref("privacy.trackingprotection.enabled");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ optional_permissions: ["privacy"],
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+ ok(!hasSetting(), "setting is not set after startup");
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request");
+ await extension.awaitMessage("done");
+ ok(hasSetting(), "setting was set after request");
+
+ extension.sendMessage("remove");
+ await extension.awaitMessage("done");
+ ok(!hasSetting(), "setting is reset after remove");
+
+ extension.sendMessage("request");
+ await extension.awaitMessage("done");
+ ok(hasSetting(), "setting was set after request");
+ });
+
+ await ExtensionPermissions._uninit();
+ extensionHandlers.delete(extension);
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitBackgroundStarted();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("remove");
+ await extension.awaitMessage("done");
+ ok(!hasSetting(), "setting is reset after remove");
+ });
+
+ await extension.unload();
+});
+
+add_task(
+ { pref_set: [["extensions.eventPages.enabled", true]] },
+ async function test_permissions_event_page() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ optional_permissions: ["privacy"],
+ background: { persistent: false },
+ },
+ background() {
+ browser.permissions.onAdded.addListener(details => {
+ browser.test.sendMessage("added", details);
+ });
+
+ browser.permissions.onRemoved.addListener(details => {
+ browser.test.sendMessage("removed", details);
+ });
+ },
+ });
+
+ await extension.startup();
+ let events = ["onAdded", "onRemoved"];
+ for (let event of events) {
+ assertPersistentListeners(extension, "permissions", event, {
+ primed: false,
+ });
+ }
+
+ await extension.terminateBackground();
+ for (let event of events) {
+ assertPersistentListeners(extension, "permissions", event, {
+ primed: true,
+ });
+ }
+
+ let permObj = {
+ permissions: ["privacy"],
+ origins: [],
+ };
+
+ // enable the permissions while the background is stopped
+ await ExtensionPermissions.add(extension.id, permObj, extension.extension);
+ let details = await extension.awaitMessage("added");
+ Assert.deepEqual(permObj, details, "got added event");
+
+ // Restart and test that permission removal wakes the background.
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+
+ for (let event of events) {
+ assertPersistentListeners(extension, "permissions", event, {
+ primed: true,
+ });
+ }
+
+ // remove the permissions while the background is stopped
+ await ExtensionPermissions.remove(
+ extension.id,
+ permObj,
+ extension.extension
+ );
+
+ details = await extension.awaitMessage("removed");
+ Assert.deepEqual(permObj, details, "got removed event");
+
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js
new file mode 100644
index 0000000000..969304676f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js
@@ -0,0 +1,268 @@
+"use strict";
+
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_task(async function setup() {
+ // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy
+ // storage mode will run in xpcshell-legacy-ep.ini
+ await ExtensionPermissions._uninit();
+
+ Services.prefs.setBoolPref(
+ "extensions.webextOptionalPermissionPrompts",
+ false
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts");
+ });
+ await AddonTestUtils.promiseStartupManager();
+ AddonTestUtils.usePrivilegedSignatures = false;
+});
+
+async function test_migrated_permission_to_optional({ manifest_version }) {
+ let id = "permission-upgrade@test";
+ let extensionData = {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id } },
+ permissions: [
+ "webRequest",
+ "tabs",
+ "http://example.net/*",
+ "http://example.com/*",
+ ],
+ },
+ useAddonManager: "permanent",
+ };
+
+ function checkPermissions() {
+ let policy = WebExtensionPolicy.getByID(id);
+ ok(policy.hasPermission("webRequest"), "addon has webRequest permission");
+ ok(policy.hasPermission("tabs"), "addon has tabs permission");
+ ok(
+ policy.canAccessURI(Services.io.newURI("http://example.net/")),
+ "addon has example.net host permission"
+ );
+ ok(
+ policy.canAccessURI(Services.io.newURI("http://example.com/")),
+ "addon has example.com host permission"
+ );
+ ok(
+ !policy.canAccessURI(Services.io.newURI("http://other.com/")),
+ "addon does not have other.com host permission"
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ checkPermissions();
+
+ extensionData.manifest.manifest_version = manifest_version;
+
+ // Move to using optional permission
+ extensionData.manifest.version = "2.0";
+ extensionData.manifest.permissions = ["tabs"];
+
+ // The ExtensionTestCommon.generateFiles() test helper will normalize (move)
+ // host permissions into the `permissions` key for the MV2 test.
+ extensionData.manifest.host_permissions = ["http://example.net/*"];
+
+ extensionData.manifest.optional_permissions = [
+ "webRequest",
+ "http://example.com/*",
+ "http://other.com/*",
+ ];
+
+ // Restart the addon manager to flush the AddonInternal instance created
+ // when installing the addon above. See bug 1622117.
+ await AddonTestUtils.promiseRestartManager();
+ await extension.upgrade(extensionData);
+
+ equal(extension.version, "2.0", "Expected extension version");
+ checkPermissions();
+
+ await extension.unload();
+}
+
+add_task(function test_migrated_permission_to_optional_mv2() {
+ return test_migrated_permission_to_optional({ manifest_version: 2 });
+});
+
+// Test migration of mv2 (required) to mv3 (optional) host permissions.
+add_task(function test_migrated_permission_to_optional_mv3() {
+ return test_migrated_permission_to_optional({ manifest_version: 3 });
+});
+
+// This tests that settings are removed if a required permission is removed.
+// We use two settings APIs to make sure the one we keep permission to is not
+// removed inadvertantly.
+add_task(async function test_required_permissions_removed() {
+ function cacheIsEnabled() {
+ return (
+ Services.prefs.getBoolPref("browser.cache.disk.enable") &&
+ Services.prefs.getBoolPref("browser.cache.memory.enable")
+ );
+ }
+
+ let extData = {
+ background() {
+ if (browser.browserSettings) {
+ browser.browserSettings.cacheEnabled.set({ value: false });
+ }
+ browser.privacy.services.passwordSavingEnabled.set({ value: false });
+ },
+ manifest: {
+ browser_specific_settings: { gecko: { id: "pref-test@test" } },
+ permissions: ["tabs", "browserSettings", "privacy", "http://test.com/*"],
+ },
+ useAddonManager: "permanent",
+ };
+ let extension = ExtensionTestUtils.loadExtension(extData);
+ ok(
+ Services.prefs.getBoolPref("signon.rememberSignons"),
+ "privacy setting intial value as expected"
+ );
+ await extension.startup();
+ ok(!cacheIsEnabled(), "setting is set after startup");
+
+ extData.manifest.permissions = ["tabs"];
+ extData.manifest.optional_permissions = ["privacy"];
+ await extension.upgrade(extData);
+ ok(cacheIsEnabled(), "setting is reset after upgrade");
+ ok(
+ !Services.prefs.getBoolPref("signon.rememberSignons"),
+ "privacy setting is still set after upgrade"
+ );
+
+ await extension.unload();
+});
+
+// This tests that settings are removed if a granted permission is removed.
+// We use two settings APIs to make sure the one we keep permission to is not
+// removed inadvertantly.
+add_task(async function test_granted_permissions_removed() {
+ function cacheIsEnabled() {
+ return (
+ Services.prefs.getBoolPref("browser.cache.disk.enable") &&
+ Services.prefs.getBoolPref("browser.cache.memory.enable")
+ );
+ }
+
+ let extData = {
+ async background() {
+ browser.test.onMessage.addListener(async msg => {
+ await browser.permissions.request({ permissions: msg.permissions });
+ if (browser.browserSettings) {
+ browser.browserSettings.cacheEnabled.set({ value: false });
+ }
+ browser.privacy.services.passwordSavingEnabled.set({ value: false });
+ browser.test.sendMessage("done");
+ });
+ },
+ // "tabs" is never granted, it is included to exercise the removal code
+ // that called during the upgrade.
+ manifest: {
+ browser_specific_settings: { gecko: { id: "pref-test@test" } },
+ optional_permissions: [
+ "tabs",
+ "browserSettings",
+ "privacy",
+ "http://test.com/*",
+ ],
+ },
+ useAddonManager: "permanent",
+ };
+ let extension = ExtensionTestUtils.loadExtension(extData);
+ ok(
+ Services.prefs.getBoolPref("signon.rememberSignons"),
+ "privacy setting intial value as expected"
+ );
+ await extension.startup();
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage({ permissions: ["browserSettings", "privacy"] });
+ await extension.awaitMessage("done");
+ });
+ ok(!cacheIsEnabled(), "setting is set after startup");
+
+ extData.manifest.permissions = ["privacy"];
+ delete extData.manifest.optional_permissions;
+ await extension.upgrade(extData);
+ ok(cacheIsEnabled(), "setting is reset after upgrade");
+ ok(
+ !Services.prefs.getBoolPref("signon.rememberSignons"),
+ "privacy setting is still set after upgrade"
+ );
+
+ await extension.unload();
+});
+
+// Test an update where an add-on becomes a theme.
+add_task(async function test_addon_to_theme_update() {
+ let id = "theme-test@test";
+ let extData = {
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ version: "1.0",
+ optional_permissions: ["tabs"],
+ },
+ async background() {
+ browser.test.onMessage.addListener(async msg => {
+ await browser.permissions.request({ permissions: msg.permissions });
+ browser.test.sendMessage("done");
+ });
+ },
+ useAddonManager: "permanent",
+ };
+ let extension = ExtensionTestUtils.loadExtension(extData);
+ await extension.startup();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage({ permissions: ["tabs"] });
+ await extension.awaitMessage("done");
+ });
+
+ let policy = WebExtensionPolicy.getByID(id);
+ ok(policy.hasPermission("tabs"), "addon has tabs permission");
+
+ await extension.upgrade({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ version: "2.0",
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ },
+ },
+ useAddonManager: "permanent",
+ });
+ // When a theme is installed, it starts off in disabled mode, as seen in
+ // toolkit/mozapps/extensions/test/xpcshell/test_update_theme.js .
+ // But if we upgrade from an enabled extension, the theme is enabled.
+ equal(extension.addon.userDisabled, false, "Theme is enabled");
+
+ policy = WebExtensionPolicy.getByID(id);
+ ok(!policy.hasPermission("tabs"), "addon tabs permission was removed");
+ let perms = await ExtensionPermissions._get(id);
+ ok(!perms?.permissions?.length, "no retained permissions");
+
+ extData.manifest.version = "3.0";
+ extData.manifest.permissions = ["privacy"];
+ await extension.upgrade(extData);
+
+ policy = WebExtensionPolicy.getByID(id);
+ ok(!policy.hasPermission("tabs"), "addon tabs permission not added");
+ ok(policy.hasPermission("privacy"), "addon privacy permission added");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js
new file mode 100644
index 0000000000..d123575cc5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js
@@ -0,0 +1,157 @@
+"use strict";
+
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+// This test doesn't need the test extensions to be detected as privileged,
+// disabling it to avoid having to keep the list of expected "internal:*"
+// permissions that are added automatically to privileged extensions
+// and already covered by other tests.
+AddonTestUtils.usePrivilegedSignatures = false;
+
+// Look up the cached permissions, if any.
+async function getCachedPermissions(extensionId) {
+ const NotFound = Symbol("extension ID not found in permissions cache");
+ try {
+ return await ExtensionParent.StartupCache.permissions.get(
+ extensionId,
+ () => {
+ // Throw error to prevent the key from being created.
+ throw NotFound;
+ }
+ );
+ } catch (e) {
+ if (e === NotFound) {
+ return null;
+ }
+ throw e;
+ }
+}
+
+// Look up the permissions from the file. Internal methods are used to avoid
+// inadvertently changing the permissions in the cache or the database.
+async function getStoredPermissions(extensionId) {
+ if (await ExtensionPermissions._has(extensionId)) {
+ return ExtensionPermissions._get(extensionId);
+ }
+ return null;
+}
+
+add_task(async function setup() {
+ // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy
+ // storage mode will run in xpcshell-legacy-ep.ini
+ await ExtensionPermissions._uninit();
+
+ optionalPermissionsPromptHandler.init();
+ optionalPermissionsPromptHandler.acceptPrompt = true;
+
+ await AddonTestUtils.promiseStartupManager();
+ registerCleanupFunction(async () => {
+ await AddonTestUtils.promiseShutdownManager();
+ });
+});
+
+// This test must run before any restart of the addonmanager so the
+// ExtensionAddonObserver works.
+add_task(async function test_permissions_removed() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ optional_permissions: ["idle"],
+ },
+ background() {
+ browser.test.onMessage.addListener(async (msg, arg) => {
+ if (msg == "request") {
+ try {
+ let result = await browser.permissions.request(arg);
+ browser.test.sendMessage("request.result", result);
+ } catch (err) {
+ browser.test.sendMessage("request.result", err.message);
+ }
+ }
+ });
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request", { permissions: ["idle"], origins: [] });
+ let result = await extension.awaitMessage("request.result");
+ equal(result, true, "request() for optional permissions succeeded");
+ });
+
+ let id = extension.id;
+ let perms = await ExtensionPermissions.get(id);
+ equal(
+ perms.permissions.length,
+ 1,
+ `optional permission added (${JSON.stringify(perms.permissions)})`
+ );
+
+ Assert.deepEqual(
+ await getCachedPermissions(id),
+ {
+ permissions: ["idle"],
+ origins: [],
+ },
+ "Optional permission added to cache"
+ );
+ Assert.deepEqual(
+ await getStoredPermissions(id),
+ {
+ permissions: ["idle"],
+ origins: [],
+ },
+ "Optional permission added to persistent file"
+ );
+
+ await extension.unload();
+
+ // Directly read from the internals instead of using ExtensionPermissions.get,
+ // because the latter will lazily cache the extension ID.
+ Assert.deepEqual(
+ await getCachedPermissions(id),
+ null,
+ "Cached permissions removed"
+ );
+ Assert.deepEqual(
+ await getStoredPermissions(id),
+ null,
+ "Stored permissions removed"
+ );
+
+ perms = await ExtensionPermissions.get(id);
+ equal(
+ perms.permissions.length,
+ 0,
+ `no permissions after uninstall (${JSON.stringify(perms.permissions)})`
+ );
+ equal(
+ perms.origins.length,
+ 0,
+ `no origin permissions after uninstall (${JSON.stringify(perms.origins)})`
+ );
+
+ // The public ExtensionPermissions.get method should not store (empty)
+ // permissions in the persistent database. Polluting the cache is not ideal,
+ // but acceptable since the cache will eventually be cleared, and non-test
+ // code is not likely to call ExtensionPermissions.get() for non-installed
+ // extensions anyway.
+ Assert.deepEqual(await getCachedPermissions(id), perms, "Permissions cached");
+ Assert.deepEqual(
+ await getStoredPermissions(id),
+ null,
+ "Permissions not saved"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js b/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js
new file mode 100644
index 0000000000..6f903ddeac
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js
@@ -0,0 +1,1636 @@
+"use strict";
+
+// Delay loading until createAppInfo is called and setup.
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+const { ExtensionAPI } = ExtensionCommon;
+
+// The code in this class does not actually run in this test scope, it is
+// serialized into a string which is later loaded by the WebExtensions
+// framework in the same context as other extension APIs. By writing it
+// this way rather than as a big string constant we get lint coverage.
+// But eslint doesn't understand that this code runs in a different context
+// where the EventManager class is available so just tell it here:
+/* global EventManager */
+const API = class extends ExtensionAPI {
+ static namespace = undefined;
+ primeListener(event, fire, params, isInStartup) {
+ if (isInStartup && event == "nonBlockingEvent") {
+ return;
+ }
+ // eslint-disable-next-line no-undef
+ let { eventName, throwError, ignoreListener } =
+ this.constructor.testOptions || {};
+ let { namespace } = this.constructor;
+
+ if (eventName == event) {
+ if (throwError) {
+ throw new Error(throwError);
+ }
+ if (ignoreListener) {
+ return;
+ }
+ }
+
+ Services.obs.notifyObservers(
+ { namespace, event, fire, params },
+ "prime-event-listener"
+ );
+
+ const FIRE_TOPIC = `fire-${namespace}.${event}`;
+
+ async function listener(subject, topic, data) {
+ try {
+ if (subject.wrappedJSObject.waitForBackground) {
+ await fire.wakeup();
+ }
+ await fire.async(subject.wrappedJSObject.listenerArgs);
+ } catch (err) {
+ let errSubject = { namespace, event, errorMessage: err.toString() };
+ Services.obs.notifyObservers(errSubject, "listener-callback-exception");
+ }
+ }
+ Services.obs.addObserver(listener, FIRE_TOPIC);
+
+ return {
+ unregister() {
+ Services.obs.notifyObservers(
+ { namespace, event, params },
+ "unregister-primed-listener"
+ );
+ Services.obs.removeObserver(listener, FIRE_TOPIC);
+ },
+ convert(_fire) {
+ Services.obs.notifyObservers(
+ { namespace, event, params },
+ "convert-event-listener"
+ );
+ fire = _fire;
+ },
+ };
+ }
+
+ getAPI(context) {
+ let self = this;
+ let { namespace } = this.constructor;
+ return {
+ [namespace]: {
+ testOptions(options) {
+ // We want to be able to test errors on startup.
+ // We use a global here because we test restarting AOM,
+ // which causes the instance of this class to be destroyed.
+ // eslint-disable-next-line no-undef
+ self.constructor.testOptions = options;
+ },
+ onEvent1: new EventManager({
+ context,
+ module: namespace,
+ event: "onEvent1",
+ register: (fire, ...params) => {
+ let data = { namespace, event: "onEvent1", params };
+ Services.obs.notifyObservers(data, "register-event-listener");
+ return () => {
+ Services.obs.notifyObservers(data, "unregister-event-listener");
+ };
+ },
+ }).api(),
+
+ onEvent2: new EventManager({
+ context,
+ module: namespace,
+ event: "onEvent2",
+ register: (fire, ...params) => {
+ let data = { namespace, event: "onEvent2", params };
+ Services.obs.notifyObservers(data, "register-event-listener");
+ return () => {
+ Services.obs.notifyObservers(data, "unregister-event-listener");
+ };
+ },
+ }).api(),
+
+ onEvent3: new EventManager({
+ context,
+ module: namespace,
+ event: "onEvent3",
+ register: (fire, ...params) => {
+ let data = { namespace, event: "onEvent3", params };
+ Services.obs.notifyObservers(data, "register-event-listener");
+ return () => {
+ Services.obs.notifyObservers(data, "unregister-event-listener");
+ };
+ },
+ }).api(),
+
+ nonBlockingEvent: new EventManager({
+ context,
+ module: namespace,
+ event: "nonBlockingEvent",
+ register: (fire, ...params) => {
+ let data = { namespace, event: "nonBlockingEvent", params };
+ Services.obs.notifyObservers(data, "register-event-listener");
+ return () => {
+ Services.obs.notifyObservers(data, "unregister-event-listener");
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
+
+function makeModule(namespace, options = {}) {
+ const SCHEMA = [
+ {
+ namespace,
+ functions: [
+ {
+ name: "testOptions",
+ type: "function",
+ async: true,
+ parameters: [
+ {
+ name: "options",
+ type: "object",
+ additionalProperties: {
+ type: "any",
+ },
+ },
+ ],
+ },
+ ],
+ events: [
+ {
+ name: "onEvent1",
+ type: "function",
+ extraParameters: [{ type: "any", optional: true }],
+ },
+ {
+ name: "onEvent2",
+ type: "function",
+ extraParameters: [{ type: "any", optional: true }],
+ },
+ {
+ name: "onEvent3",
+ type: "function",
+ extraParameters: [
+ { type: "object", optional: true, additionalProperties: true },
+ { type: "any", optional: true },
+ ],
+ },
+ {
+ name: "nonBlockingEvent",
+ type: "function",
+ extraParameters: [{ type: "any", optional: true }],
+ },
+ ],
+ },
+ ];
+
+ const API_SCRIPT = `
+ this.${namespace} = ${API.toString()};
+ this.${namespace}.namespace = "${namespace}";
+ `;
+
+ // MODULE_INFO for registerModules
+ let { startupBlocking } = options;
+ return {
+ schema: `data:,${JSON.stringify(SCHEMA)}`,
+ scopes: ["addon_parent"],
+ paths: [[namespace]],
+ startupBlocking,
+ url: URL.createObjectURL(new Blob([API_SCRIPT])),
+ };
+}
+
+// Two modules, primary test module is startupBlocking
+const MODULE_INFO = {
+ startupBlocking: makeModule("startupBlocking", { startupBlocking: true }),
+ nonStartupBlocking: makeModule("nonStartupBlocking"),
+};
+
+const global = this;
+
+// Wait for the given event (topic) to occur a specific number of times
+// (count). If fn is not supplied, the Promise returned from this function
+// resolves as soon as that many instances of the event have been observed.
+// If fn is supplied, this function also waits for the Promise that fn()
+// returns to complete and ensures that the given event does not occur more
+// than `count` times before then. On success, resolves with an array
+// of the subjects from each of the observed events.
+async function promiseObservable(topic, count, fn = null) {
+ let _countResolve;
+ let results = [];
+ function listener(subject, _topic, data) {
+ const eventDetails = subject.wrappedJSObject;
+ results.push(eventDetails);
+ if (results.length > count) {
+ ok(
+ false,
+ `Got unexpected ${topic} event with ${JSON.stringify(eventDetails)}`
+ );
+ } else if (results.length == count) {
+ _countResolve();
+ }
+ }
+ Services.obs.addObserver(listener, topic);
+
+ try {
+ await Promise.all([
+ new Promise(resolve => {
+ _countResolve = resolve;
+ }),
+ fn && fn(),
+ ]);
+ } finally {
+ Services.obs.removeObserver(listener, topic);
+ }
+
+ return results;
+}
+
+function trackEvents(wrapper) {
+ let events = new Map();
+ for (let event of ["background-script-event", "start-background-script"]) {
+ events.set(event, false);
+ wrapper.extension.once(event, () => events.set(event, true));
+ }
+ return events;
+}
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_setup(async function setup() {
+ // The blob:-URL registered above in MODULE_INFO gets loaded at
+ // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649
+ Services.prefs.setBoolPref(
+ "security.allow_parent_unrestricted_js_loads",
+ true
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads");
+ });
+
+ AddonTestUtils.init(global);
+ AddonTestUtils.overrideCertDB();
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+ );
+
+ ExtensionParent.apiManager.registerModules(MODULE_INFO);
+});
+
+add_task(async function test_persistent_events() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background() {
+ let register1 = true,
+ register2 = true;
+ if (localStorage.getItem("skip1")) {
+ register1 = false;
+ }
+ if (localStorage.getItem("skip2")) {
+ register2 = false;
+ }
+
+ let listener1 = arg => browser.test.sendMessage("listener1", arg);
+ let listener2 = arg => browser.test.sendMessage("listener2", arg);
+ let listener3 = arg => browser.test.sendMessage("listener3", arg);
+
+ if (register1) {
+ browser.startupBlocking.onEvent1.addListener(listener1, "listener1");
+ }
+ if (register2) {
+ browser.startupBlocking.onEvent1.addListener(listener2, "listener2");
+ browser.startupBlocking.onEvent2.addListener(listener3, "listener3");
+ }
+
+ browser.test.onMessage.addListener(msg => {
+ if (msg == "unregister2") {
+ browser.startupBlocking.onEvent2.removeListener(listener3);
+ localStorage.setItem("skip2", true);
+ } else if (msg == "unregister1") {
+ localStorage.setItem("skip1", true);
+ browser.test.sendMessage("unregistered");
+ }
+ });
+
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ function check(
+ info,
+ what,
+ { listener1 = true, listener2 = true, listener3 = true } = {}
+ ) {
+ let count = (listener1 ? 1 : 0) + (listener2 ? 1 : 0) + (listener3 ? 1 : 0);
+ equal(info.length, count, `Got ${count} ${what} events`);
+
+ let i = 0;
+ if (listener1) {
+ equal(info[i].event, "onEvent1", `Got ${what} on event1 for listener 1`);
+ deepEqual(
+ info[i].params,
+ ["listener1"],
+ `Got event1 ${what} args for listener 1`
+ );
+ ++i;
+ }
+
+ if (listener2) {
+ equal(info[i].event, "onEvent1", `Got ${what} on event1 for listener 2`);
+ deepEqual(
+ info[i].params,
+ ["listener2"],
+ `Got event1 ${what} args for listener 2`
+ );
+ ++i;
+ }
+
+ if (listener3) {
+ equal(info[i].event, "onEvent2", `Got ${what} on event2 for listener 3`);
+ deepEqual(
+ info[i].params,
+ ["listener3"],
+ `Got event2 ${what} args for listener 3`
+ );
+ ++i;
+ }
+ }
+
+ // Check that the regular event registration process occurs when
+ // the extension is installed.
+ let [observed] = await Promise.all([
+ promiseObservable("register-event-listener", 3),
+ extension.startup(),
+ ]);
+ check(observed, "register");
+
+ await extension.awaitMessage("ready");
+
+ // Check that the regular unregister process occurs when
+ // the browser shuts down.
+ [observed] = await Promise.all([
+ promiseObservable("unregister-event-listener", 3),
+ new Promise(resolve => extension.extension.once("shutdown", resolve)),
+ AddonTestUtils.promiseShutdownManager(),
+ ]);
+ check(observed, "unregister");
+
+ // Check that listeners are primed at the next browser startup.
+ [observed] = await Promise.all([
+ promiseObservable("prime-event-listener", 3),
+ AddonTestUtils.promiseStartupManager(),
+ ]);
+ check(observed, "prime");
+
+ assertPersistentListeners(extension, "startupBlocking", "onEvent1", {
+ primed: true,
+ primedListenersCount: 2,
+ });
+
+ assertPersistentListeners(extension, "startupBlocking", "onEvent2", {
+ primed: true,
+ primedListenersCount: 1,
+ });
+
+ // Check that primed listeners are converted to regular listeners
+ // when the background page is started after browser startup.
+ let p = promiseObservable("convert-event-listener", 3);
+ AddonTestUtils.notifyLateStartup();
+ observed = await p;
+
+ check(observed, "convert");
+
+ await extension.awaitMessage("ready");
+
+ // Check that when the event is triggered, all the plumbing worked
+ // correctly for the primed-then-converted listener.
+ let listenerArgs = { test: "kaboom" };
+ Services.obs.notifyObservers(
+ { listenerArgs },
+ "fire-startupBlocking.onEvent1"
+ );
+
+ let details = await extension.awaitMessage("listener1");
+ deepEqual(details, listenerArgs, "Listener 1 fired");
+ details = await extension.awaitMessage("listener2");
+ deepEqual(details, listenerArgs, "Listener 2 fired");
+
+ // Check that the converted listener is properly unregistered at
+ // browser shutdown.
+ [observed] = await Promise.all([
+ promiseObservable("unregister-primed-listener", 3),
+ AddonTestUtils.promiseShutdownManager(),
+ ]);
+ check(observed, "unregister");
+
+ // Start up again, listener should be primed
+ [observed] = await Promise.all([
+ promiseObservable("prime-event-listener", 3),
+ AddonTestUtils.promiseStartupManager(),
+ ]);
+ check(observed, "prime");
+
+ // Check that triggering the event before the listener has been converted
+ // causes the background page to be loaded and the listener to be converted,
+ // and the listener is invoked.
+ p = promiseObservable("convert-event-listener", 3);
+ listenerArgs.test = "startup event";
+ Services.obs.notifyObservers(
+ { listenerArgs },
+ "fire-startupBlocking.onEvent2"
+ );
+ observed = await p;
+
+ check(observed, "convert");
+
+ details = await extension.awaitMessage("listener3");
+ deepEqual(details, listenerArgs, "Listener 3 fired for event during startup");
+
+ await extension.awaitMessage("ready");
+
+ // Check that triggering onEvent1 emits calls to both listener1 and listener2
+ // (See Bug 1795801).
+ [observed] = await Promise.all([
+ promiseObservable("unregister-primed-listener", 3),
+ AddonTestUtils.promiseShutdownManager(),
+ ]);
+ check(observed, "unregister");
+ [observed] = await Promise.all([
+ promiseObservable("prime-event-listener", 3),
+ AddonTestUtils.promiseStartupManager(),
+ ]);
+ check(observed, "prime");
+ assertPersistentListeners(extension, "startupBlocking", "onEvent1", {
+ primed: true,
+ primedListenersCount: 2,
+ });
+ assertPersistentListeners(extension, "startupBlocking", "onEvent2", {
+ primed: true,
+ primedListenersCount: 1,
+ });
+
+ p = promiseObservable("convert-event-listener", 3);
+ listenerArgs.test = "startup event";
+ Services.obs.notifyObservers(
+ { listenerArgs },
+ "fire-startupBlocking.onEvent1"
+ );
+ observed = await p;
+
+ check(observed, "convert");
+
+ const [detailsListener1Call, detailsListener2Call] = await Promise.all([
+ extension.awaitMessage("listener1"),
+ extension.awaitMessage("listener2"),
+ ]);
+ deepEqual(
+ detailsListener1Call,
+ listenerArgs,
+ "Listener 1 fired for event during startup"
+ );
+ deepEqual(
+ detailsListener2Call,
+ listenerArgs,
+ "Listener 2 fired for event during startup"
+ );
+
+ await extension.awaitMessage("ready");
+
+ // Check that the unregister process works when we manually remove
+ // a listener.
+ p = promiseObservable("unregister-primed-listener", 1);
+ extension.sendMessage("unregister2");
+ observed = await p;
+ check(observed, "unregister", { listener1: false, listener2: false });
+
+ // Check that we only get unregisters for the remaining events after
+ // one listener has been removed.
+ observed = await promiseObservable("unregister-primed-listener", 2, () =>
+ AddonTestUtils.promiseShutdownManager()
+ );
+ check(observed, "unregister", { listener3: false });
+
+ // Check that after restart, only listeners that were present at
+ // the end of the last session are primed.
+ observed = await promiseObservable("prime-event-listener", 2, () =>
+ AddonTestUtils.promiseStartupManager()
+ );
+ check(observed, "prime", { listener3: false });
+
+ // Check that if the background script does not re-register listeners,
+ // the primed listeners are unregistered after the background page
+ // starts up.
+ p = promiseObservable("unregister-primed-listener", 1, () =>
+ extension.awaitMessage("ready")
+ );
+
+ AddonTestUtils.notifyLateStartup();
+ observed = await p;
+ check(observed, "unregister", { listener1: false, listener3: false });
+
+ // Just listener1 should be registered now, fire event1 to confirm.
+ listenerArgs.test = "third time";
+ Services.obs.notifyObservers(
+ { listenerArgs },
+ "fire-startupBlocking.onEvent1"
+ );
+ details = await extension.awaitMessage("listener1");
+ deepEqual(details, listenerArgs, "Listener 1 fired");
+
+ // Tell the extension not to re-register listener1 on the next startup
+ extension.sendMessage("unregister1");
+ await extension.awaitMessage("unregistered");
+
+ // Shut down, start up
+ observed = await promiseObservable("unregister-primed-listener", 1, () =>
+ AddonTestUtils.promiseShutdownManager()
+ );
+ check(observed, "unregister", { listener2: false, listener3: false });
+
+ observed = await promiseObservable("prime-event-listener", 1, () =>
+ AddonTestUtils.promiseStartupManager()
+ );
+ check(observed, "register", { listener2: false, listener3: false });
+
+ // Check that firing event1 causes the listener fire callback to
+ // reject.
+ p = promiseObservable("listener-callback-exception", 1);
+ Services.obs.notifyObservers(
+ { listenerArgs, waitForBackground: true },
+ "fire-startupBlocking.onEvent1"
+ );
+ equal(
+ (await p)[0].errorMessage,
+ "Error: primed listener startupBlocking.onEvent1 not re-registered",
+ "Primed listener that was not re-registered received an error when event was triggered during startup"
+ );
+
+ await extension.awaitMessage("ready");
+
+ await extension.unload();
+
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// This test checks whether primed listeners are correctly unregistered when
+// a background page load is interrupted. In particular, it verifies that the
+// fire.wakeup() and fire.async() promises settle eventually.
+add_task(async function test_shutdown_before_background_loaded() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background() {
+ let listener = arg => browser.test.sendMessage("triggered", arg);
+ browser.startupBlocking.onEvent1.addListener(listener, "triggered");
+ browser.test.sendMessage("bg_started");
+ },
+ });
+ await Promise.all([
+ promiseObservable("register-event-listener", 1),
+ extension.startup(),
+ ]);
+ await extension.awaitMessage("bg_started");
+
+ await Promise.all([
+ promiseObservable("unregister-event-listener", 1),
+ new Promise(resolve => extension.extension.once("shutdown", resolve)),
+ AddonTestUtils.promiseShutdownManager(),
+ ]);
+
+ let primeListenerPromise = promiseObservable("prime-event-listener", 1);
+ let fire;
+ let fireWakeupBeforeBgFail;
+ let fireAsyncBeforeBgFail;
+
+ let bgAbortedPromise = new Promise(resolve => {
+ let Management = ExtensionParent.apiManager;
+ Management.once("extension-browser-inserted", (eventName, browser) => {
+ browser.fixupAndLoadURIString = async () => {
+ // The fire.wakeup/fire.async promises created while loading the
+ // background page should settle when the page fails to load.
+ fire = (await primeListenerPromise)[0].fire;
+ fireWakeupBeforeBgFail = fire.wakeup();
+ fireAsyncBeforeBgFail = fire.async();
+
+ extension.extension.once("background-script-aborted", resolve);
+ info("Forcing the background load to fail");
+ browser.remove();
+ };
+ });
+ });
+
+ let unregisterPromise = promiseObservable("unregister-primed-listener", 1);
+
+ await Promise.all([
+ primeListenerPromise,
+ AddonTestUtils.promiseStartupManager(),
+ ]);
+ await bgAbortedPromise;
+ info("Loaded extension and aborted load of background page");
+
+ await unregisterPromise;
+ info("Primed listener has been unregistered");
+
+ await fireWakeupBeforeBgFail;
+ info("fire.wakeup() before background load failure should settle");
+
+ await Assert.rejects(
+ fireAsyncBeforeBgFail,
+ /Error: listener not re-registered/,
+ "fire.async before background load failure should be rejected"
+ );
+
+ await fire.wakeup();
+ info("fire.wakeup() after background load failure should settle");
+
+ await Assert.rejects(
+ fire.async(),
+ /Error: primed listener startupBlocking.onEvent1 not re-registered/,
+ "fire.async after background load failure should be rejected"
+ );
+
+ await AddonTestUtils.promiseShutdownManager();
+
+ // End of the abnormal shutdown test. Now restart the extension to verify
+ // that the persistent listeners have not been unregistered.
+
+ // Suppress background page start until an explicit notification.
+ await Promise.all([
+ promiseObservable("prime-event-listener", 1),
+ AddonTestUtils.promiseStartupManager({ earlyStartup: false }),
+ ]);
+ info("Triggering persistent event to force the background page to start");
+ Services.obs.notifyObservers(
+ { listenerArgs: 123 },
+ "fire-startupBlocking.onEvent1"
+ );
+ AddonTestUtils.notifyEarlyStartup();
+ await extension.awaitMessage("bg_started");
+ equal(await extension.awaitMessage("triggered"), 123, "triggered event");
+
+ await Promise.all([
+ promiseObservable("unregister-primed-listener", 1),
+ AddonTestUtils.promiseShutdownManager(),
+ ]);
+
+ // And lastly, verify that a primed listener is correctly removed when the
+ // extension unloads normally before the delayed background page can load.
+ await Promise.all([
+ promiseObservable("prime-event-listener", 1),
+ AddonTestUtils.promiseStartupManager({ earlyStartup: false }),
+ ]);
+
+ info("Unloading extension before background page has loaded");
+ await Promise.all([
+ promiseObservable("unregister-primed-listener", 1),
+ extension.unload(),
+ ]);
+
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// This test checks whether primed listeners are correctly primed to
+// restart the background once the background has been shutdown or
+// put to sleep.
+add_task(async function test_background_restarted() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background() {
+ let listener = arg => browser.test.sendMessage("triggered", arg);
+ browser.startupBlocking.onEvent1.addListener(listener, "triggered");
+ browser.test.sendMessage("bg_started");
+ },
+ });
+ await Promise.all([
+ promiseObservable("register-event-listener", 1),
+ extension.startup(),
+ ]);
+ await extension.awaitMessage("bg_started");
+ assertPersistentListeners(extension, "startupBlocking", "onEvent1", {
+ primed: false,
+ });
+
+ // Shutdown the background page
+ await Promise.all([
+ promiseObservable("unregister-event-listener", 1),
+ extension.terminateBackground(),
+ ]);
+ // When sleeping the background, its events should become persisted
+ assertPersistentListeners(extension, "startupBlocking", "onEvent1", {
+ primed: true,
+ });
+
+ info("Triggering persistent event to force the background page to start");
+ Services.obs.notifyObservers(
+ { listenerArgs: 123 },
+ "fire-startupBlocking.onEvent1"
+ );
+ await extension.awaitMessage("bg_started");
+ equal(await extension.awaitMessage("triggered"), 123, "triggered event");
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// This test checks whether primed listeners are correctly primed to
+// restart the background once the background has been shutdown or
+// put to sleep.
+add_task(
+ { pref_set: [["extensions.eventPages.enabled", true]] },
+ async function test_eventpage_startup() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "eventpage@test" } },
+ background: { persistent: false },
+ },
+ background() {
+ let listener = arg => browser.test.sendMessage("triggered", arg);
+ browser.startupBlocking.onEvent1.addListener(listener, "triggered");
+ let listenerNs = arg => browser.test.sendMessage("triggered-et2", arg);
+ browser.nonStartupBlocking.onEvent1.addListener(
+ listenerNs,
+ "triggered-et2"
+ );
+ browser.test.onMessage.addListener(() => {
+ let listener = arg => browser.test.sendMessage("triggered2", arg);
+ browser.startupBlocking.onEvent2.addListener(listener, "triggered2");
+ browser.test.sendMessage("async-registered-listener");
+ });
+ browser.test.sendMessage("bg_started");
+ },
+ });
+ await Promise.all([
+ promiseObservable("register-event-listener", 2),
+ extension.startup(),
+ ]);
+ await extension.awaitMessage("bg_started");
+ extension.sendMessage("async-register-listener");
+ await extension.awaitMessage("async-registered-listener");
+
+ async function testAfterRestart() {
+ assertPersistentListeners(extension, "startupBlocking", "onEvent1", {
+ primed: true,
+ });
+ // async registration should not be primed or persisted
+ assertPersistentListeners(extension, "startupBlocking", "onEvent2", {
+ primed: false,
+ persisted: false,
+ });
+
+ let events = trackEvents(extension);
+ ok(
+ !events.get("background-script-event"),
+ "Should not have received a background script event"
+ );
+ ok(
+ !events.get("start-background-script"),
+ "Background script should not be started"
+ );
+
+ info("Triggering persistent event to force the background page to start");
+ let converted = promiseObservable("convert-event-listener", 1);
+ Services.obs.notifyObservers(
+ { listenerArgs: 123 },
+ "fire-startupBlocking.onEvent1"
+ );
+ await extension.awaitMessage("bg_started");
+ await converted;
+ equal(await extension.awaitMessage("triggered"), 123, "triggered event");
+ ok(
+ events.get("background-script-event"),
+ "Should have received a background script event"
+ );
+ ok(
+ events.get("start-background-script"),
+ "Background script should be started"
+ );
+ }
+
+ // Shutdown the background page
+ await Promise.all([
+ promiseObservable("unregister-event-listener", 3),
+ new Promise(resolve => extension.extension.once("shutdown", resolve)),
+ AddonTestUtils.promiseShutdownManager(),
+ ]);
+ await AddonTestUtils.promiseStartupManager({ lateStartup: false });
+ await extension.awaitStartup();
+ assertPersistentListeners(extension, "nonStartupBlocking", "onEvent1", {
+ primed: false,
+ persisted: true,
+ });
+ await testAfterRestart();
+
+ extension.sendMessage("async-register-listener");
+ await extension.awaitMessage("async-registered-listener");
+
+ // We sleep twice to ensure startup and shutdown work correctly
+ info("test event listener registration during termination");
+ let registrationEvents = Promise.all([
+ promiseObservable("unregister-event-listener", 2),
+ promiseObservable("unregister-primed-listener", 1),
+ promiseObservable("prime-event-listener", 2),
+ ]);
+ await extension.terminateBackground();
+ await registrationEvents;
+
+ assertPersistentListeners(extension, "nonStartupBlocking", "onEvent1", {
+ primed: true,
+ persisted: true,
+ });
+
+ // Ensure onEvent2 does not fire, testAfterRestart will fail otherwise.
+ Services.obs.notifyObservers(
+ { listenerArgs: 123 },
+ "fire-startupBlocking.onEvent2"
+ );
+ await testAfterRestart();
+
+ registrationEvents = Promise.all([
+ promiseObservable("unregister-primed-listener", 2),
+ promiseObservable("prime-event-listener", 2),
+ ]);
+ await extension.terminateBackground();
+ await registrationEvents;
+ await testAfterRestart();
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
+
+// This test verifies primeListener behavior for errors or ignored listeners.
+add_task(async function test_background_primeListener_errors() {
+ await AddonTestUtils.promiseStartupManager();
+
+ // The internal APIs to shutdown the background work with any
+ // background, and in the shutdown case, events will be persisted
+ // and primed for a restart.
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background() {
+ // Listen for options being set so a restart will have them.
+ browser.test.onMessage.addListener(async (message, options) => {
+ if (message == "set-options") {
+ await browser.startupBlocking.testOptions(options);
+ browser.test.sendMessage("set-options:done");
+ }
+ });
+ let listener = arg => browser.test.sendMessage("triggered", arg);
+ browser.startupBlocking.onEvent1.addListener(listener, "triggered");
+ let listener2 = arg => browser.test.sendMessage("triggered", arg);
+ browser.startupBlocking.onEvent2.addListener(listener2, "triggered");
+ browser.test.sendMessage("bg_started");
+ },
+ });
+ await Promise.all([
+ promiseObservable("register-event-listener", 1),
+ extension.startup(),
+ ]);
+ await extension.awaitMessage("bg_started");
+ assertPersistentListeners(extension, "startupBlocking", "onEvent1", {
+ primed: false,
+ });
+
+ // If an event is removed from an api, a permission is removed,
+ // or some other option prevents priming, ensure that
+ // primelistener works correctly.
+ // In this scenario we are testing that an event is not renewed
+ // on startup because the API does not re-prime it. The result
+ // is that the event is also not persisted. However the other
+ // events that are renewed should still be primed and persisted.
+ extension.sendMessage("set-options", {
+ eventName: "onEvent1",
+ ignoreListener: true,
+ });
+ await extension.awaitMessage("set-options:done");
+
+ // Shutdown the background page
+ await Promise.all([
+ promiseObservable("unregister-event-listener", 2),
+ extension.terminateBackground(),
+ ]);
+ // startupBlocking.onEvent1 was not re-primed and should not be persisted, but
+ // onEvent2 should still be primed and persisted.
+ assertPersistentListeners(extension, "startupBlocking", "onEvent1", {
+ primed: false,
+ persisted: false,
+ });
+ assertPersistentListeners(extension, "startupBlocking", "onEvent2", {
+ primed: true,
+ });
+
+ info("Triggering persistent event to force the background page to start");
+ Services.obs.notifyObservers(
+ { listenerArgs: 123 },
+ "fire-startupBlocking.onEvent2"
+ );
+ await extension.awaitMessage("bg_started");
+ equal(await extension.awaitMessage("triggered"), 123, "triggered event");
+
+ // On restart, test an exception, it should not be re-primed.
+ extension.sendMessage("set-options", {
+ eventName: "onEvent1",
+ throwError: "error",
+ });
+ await extension.awaitMessage("set-options:done");
+
+ // Shutdown the background page
+ await Promise.all([
+ promiseObservable("unregister-event-listener", 1),
+ extension.terminateBackground(),
+ ]);
+ // startupBlocking.onEvent1 failed and should not be persisted
+ assertPersistentListeners(extension, "startupBlocking", "onEvent1", {
+ primed: false,
+ persisted: false,
+ });
+
+ info("Triggering event to verify background starts after prior error");
+ Services.obs.notifyObservers(
+ { listenerArgs: 123 },
+ "fire-startupBlocking.onEvent2"
+ );
+ await extension.awaitMessage("bg_started");
+ equal(await extension.awaitMessage("triggered"), 123, "triggered event");
+
+ info("reset options for next test");
+ extension.sendMessage("set-options", {});
+ await extension.awaitMessage("set-options:done");
+
+ // Test errors on app restart
+ info("Test errors during app startup");
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+ await extension.awaitMessage("bg_started");
+
+ info("restart AOM and verify primed listener");
+ await AddonTestUtils.promiseRestartManager({ earlyStartup: false });
+ await extension.awaitStartup();
+ assertPersistentListeners(extension, "startupBlocking", "onEvent1", {
+ primed: true,
+ persisted: true,
+ });
+ AddonTestUtils.notifyEarlyStartup();
+
+ Services.obs.notifyObservers(
+ { listenerArgs: 123 },
+ "fire-startupBlocking.onEvent1"
+ );
+ await extension.awaitMessage("bg_started");
+ equal(await extension.awaitMessage("triggered"), 123, "triggered event");
+
+ // Test that an exception happening during priming clears the
+ // event from being persisted when restarting the browser, and that
+ // the background correctly starts.
+ info("test exception during primeListener on startup");
+ extension.sendMessage("set-options", {
+ eventName: "onEvent1",
+ throwError: "error",
+ });
+ await extension.awaitMessage("set-options:done");
+
+ await AddonTestUtils.promiseRestartManager({ earlyStartup: false });
+ await extension.awaitStartup();
+ AddonTestUtils.notifyEarlyStartup();
+
+ // At this point, the exception results in the persisted entry
+ // being cleared.
+ assertPersistentListeners(extension, "startupBlocking", "onEvent1", {
+ primed: false,
+ persisted: false,
+ });
+
+ AddonTestUtils.notifyLateStartup();
+
+ await extension.awaitMessage("bg_started");
+
+ // The background added the listener back during top level execution,
+ // verify it is in the persisted list.
+ assertPersistentListeners(extension, "startupBlocking", "onEvent1", {
+ primed: false,
+ persisted: true,
+ });
+
+ // reset options
+ extension.sendMessage("set-options", {});
+ await extension.awaitMessage("set-options:done");
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+add_task(async function test_non_background_context_listener_not_persisted() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background() {
+ let listener = arg => browser.test.sendMessage("triggered", arg);
+ browser.startupBlocking.onEvent1.addListener(listener, "triggered");
+ browser.test.sendMessage(
+ "bg_started",
+ browser.runtime.getURL("extpage.html")
+ );
+ },
+ files: {
+ "extpage.html": `<script src="extpage.js"></script>`,
+ "extpage.js": function () {
+ let listener = arg =>
+ browser.test.sendMessage("extpage-triggered", arg);
+ browser.startupBlocking.onEvent2.addListener(
+ listener,
+ "extpage-triggered"
+ );
+ // Send a message to signal the extpage has registered the listener,
+ // after calling an async method and wait it to be resolved to make sure
+ // the addListener call to have been handled in the parent process by
+ // the time we will assert the persisted listeners.
+ browser.runtime.getPlatformInfo().then(() => {
+ browser.test.sendMessage("extpage_started");
+ });
+ },
+ },
+ });
+
+ await extension.startup();
+ const extpage_url = await extension.awaitMessage("bg_started");
+
+ assertPersistentListeners(extension, "startupBlocking", "onEvent1", {
+ persisted: true,
+ primed: false,
+ });
+
+ assertPersistentListeners(extension, "startupBlocking", "onEvent2", {
+ persisted: false,
+ });
+
+ const page = await ExtensionTestUtils.loadContentPage(extpage_url);
+ await extension.awaitMessage("extpage_started");
+
+ // Expect the onEvent2 listener subscribed by the extpage to not be persisted.
+ assertPersistentListeners(extension, "startupBlocking", "onEvent2", {
+ persisted: false,
+ });
+
+ await page.close();
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// Test support for event page tests
+const background = async function () {
+ let listener2 = () =>
+ browser.test.sendMessage("triggered:non-startupblocking");
+ browser.startupBlocking.onEvent1.addListener(() => {});
+ browser.startupBlocking.nonBlockingEvent.addListener(() => {});
+ browser.nonStartupBlocking.onEvent2.addListener(listener2);
+ browser.test.sendMessage("bg_started");
+};
+
+const background_update = async function () {
+ browser.startupBlocking.onEvent1.addListener(() => {});
+ browser.nonStartupBlocking.onEvent2.addListener(() => {});
+ browser.test.sendMessage("updated_bg_started");
+};
+
+function testPersistentListeners(extension, expect) {
+ for (let [ns, event, persisted, primed] of expect) {
+ assertPersistentListeners(extension, ns, event, {
+ persisted,
+ primed,
+ });
+ }
+}
+
+add_task(
+ { pref_set: [["extensions.eventPages.enabled", true]] },
+ async function test_startupblocking_behavior() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ background: { persistent: false },
+ },
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bg_started");
+
+ // All are persisted on startup
+ testPersistentListeners(extension, [
+ ["startupBlocking", "onEvent1", true, false],
+ ["startupBlocking", "nonBlockingEvent", true, false],
+ ["nonStartupBlocking", "onEvent2", true, false],
+ ]);
+
+ info("Test after mocked browser restart");
+ await Promise.all([
+ new Promise(resolve => extension.extension.once("shutdown", resolve)),
+ AddonTestUtils.promiseShutdownManager(),
+ ]);
+ await AddonTestUtils.promiseStartupManager({ lateStartup: false });
+ await extension.awaitStartup();
+
+ testPersistentListeners(extension, [
+ // Startup blocking event is expected to be persisted and primed.
+ ["startupBlocking", "onEvent1", true, true],
+ // A non-startup-blocking event shouldn't be primed yet.
+ ["startupBlocking", "nonBlockingEvent", true, false],
+ // Non "Startup blocking" event is expected to be persisted but not primed yet.
+ ["nonStartupBlocking", "onEvent2", true, false],
+ ]);
+
+ // Complete the browser startup and fire the startup blocking event
+ // to let the backgrund script to run.
+ AddonTestUtils.notifyLateStartup();
+ Services.obs.notifyObservers({}, "fire-startupBlocking.onEvent1");
+ await extension.awaitMessage("bg_started");
+
+ info("Test after terminate background script");
+ await extension.terminateBackground();
+
+ // After the background is terminated, all are persisted and primed.
+ testPersistentListeners(extension, [
+ ["startupBlocking", "onEvent1", true, true],
+ ["startupBlocking", "nonBlockingEvent", true, true],
+ ["nonStartupBlocking", "onEvent2", true, true],
+ ]);
+
+ info("Notify event for the non-startupBlocking API event");
+ Services.obs.notifyObservers({}, "fire-nonStartupBlocking.onEvent2");
+ await extension.awaitMessage("bg_started");
+ await extension.awaitMessage("triggered:non-startupblocking");
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
+
+add_task(
+ { pref_set: [["extensions.eventPages.enabled", true]] },
+ async function test_startupblocking_behavior_upgrade() {
+ let id = "persistent-upgrade@test";
+ await AddonTestUtils.promiseStartupManager();
+
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: { id },
+ },
+ background: { persistent: false },
+ },
+ background,
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("bg_started");
+
+ // All are persisted on startup
+ testPersistentListeners(extension, [
+ ["startupBlocking", "onEvent1", true, false],
+ ["startupBlocking", "nonBlockingEvent", true, false],
+ ["nonStartupBlocking", "onEvent2", true, false],
+ ]);
+
+ // Prepare the extension that will be updated.
+ extensionData.manifest.version = "2.0";
+ extensionData.background = background_update;
+
+ info("Test after a upgrade");
+ await extension.upgrade(extensionData);
+ // upgrade should start the background
+ await extension.awaitMessage("updated_bg_started");
+
+ // Nothing should be primed at this point after the background
+ // has started. We look specifically for nonBlockingEvent to
+ // no longer be a part of the persisted listeners.
+ testPersistentListeners(extension, [
+ ["startupBlocking", "onEvent1", true, false],
+ ["startupBlocking", "nonBlockingEvent", false, false],
+ ["nonStartupBlocking", "onEvent2", true, false],
+ ]);
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
+
+add_task(
+ { pref_set: [["extensions.eventPages.enabled", true]] },
+ async function test_startupblocking_behavior_staged_upgrade() {
+ AddonManager.checkUpdateSecurity = false;
+ let id = "persistent-staged-upgrade@test";
+
+ // register an update file.
+ AddonTestUtils.registerJSON(server, "/test_update.json", {
+ addons: {
+ [id]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link:
+ "http://example.com/addons/test_settings_staged_restart.xpi",
+ },
+ ],
+ },
+ },
+ });
+
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: { id, update_url: `http://example.com/test_update.json` },
+ },
+ background: { persistent: false },
+ },
+ background: background_update,
+ };
+
+ // Prepare the update first.
+ server.registerFile(
+ `/addons/test_settings_staged_restart.xpi`,
+ AddonTestUtils.createTempWebExtensionFile(extensionData)
+ );
+
+ // Prepare the extension that will be updated.
+ extensionData.manifest.version = "1.0";
+ extensionData.background = async function () {
+ // we're testing persistence, not operation, so no action in listeners.
+ browser.startupBlocking.onEvent1.addListener(() => {});
+ // nonBlockingEvent will be removed on upgrade
+ browser.startupBlocking.nonBlockingEvent.addListener(() => {});
+ browser.nonStartupBlocking.onEvent2.addListener(() => {});
+
+ // Force a staged updated.
+ browser.runtime.onUpdateAvailable.addListener(async details => {
+ // This should be the version of the pending update.
+ browser.test.assertEq("2.0", details.version, "correct version");
+ browser.test.sendMessage("delay");
+ });
+
+ browser.test.sendMessage("bg_started");
+ };
+
+ await AddonTestUtils.promiseStartupManager();
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+ await extension.awaitMessage("bg_started");
+
+ // All are persisted but not primed on startup
+ testPersistentListeners(extension, [
+ ["startupBlocking", "onEvent1", true, false],
+ ["startupBlocking", "nonBlockingEvent", true, false],
+ ["nonStartupBlocking", "onEvent2", true, false],
+ ]);
+
+ info("Test after a staged update");
+ // first, deal with getting and staging an upgrade
+ let addon = await AddonManager.getAddonByID(id);
+ Assert.equal(addon.version, "1.0", "1.0 is loaded");
+
+ let update = await AddonTestUtils.promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+ Assert.ok(install, `install is available ${update.error}`);
+
+ await AddonTestUtils.promiseCompleteAllInstalls([install]);
+
+ Assert.equal(
+ install.state,
+ AddonManager.STATE_POSTPONED,
+ "update is staged for install"
+ );
+ await extension.awaitMessage("delay");
+
+ await AddonTestUtils.promiseShutdownManager();
+
+ // restarting allows upgrade to proceed
+ await AddonTestUtils.promiseStartupManager();
+ // upgrade should always start the background
+ await extension.awaitMessage("updated_bg_started");
+
+ // Since this is an upgraded addon, the background will have started
+ // and we no longer have primed listeners. Check only the persisted
+ // values, and that nonBlockingEvent is not persisted.
+ testPersistentListeners(extension, [
+ ["startupBlocking", "onEvent1", true, false],
+ ["startupBlocking", "nonBlockingEvent", false, false],
+ ["nonStartupBlocking", "onEvent2", true, false],
+ ]);
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
+
+// Regression test for Bug 1795801:
+// - verifies that multiple listeners sharing the same event and set of extra
+// params are being stored in the startupData and then all primed on the next
+// startup
+// - verifies behaviors expected when startupData stored from an older
+// Firefox version (one that didn't include Bug 1795801 changes) is
+// loaded from a new Firefox version
+// - a small smoke test to also verify the behaviors when startupData stored
+// by a newer version is being loaded by an older one (where Bug 1795801
+// changes have not been introduced yet).
+add_task(async function test_migrate_startupData_to_new_format() {
+ await AddonTestUtils.promiseStartupManager();
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ background: { persistent: false },
+ },
+ background() {
+ const eventParams = [
+ { fromCustomParam1: "value1" },
+ ["fromCustomParam2"],
+ ];
+ const otherEventParams = [
+ { fromCustomParam1: "value2" },
+ ["fromCustomParam2Other"],
+ ];
+ browser.nonStartupBlocking.onEvent3.addListener(function listener1(arg) {
+ browser.test.log("listener1 called on nonStartupBlocking.onEvent3");
+ browser.test.sendMessage("listener1", arg);
+ }, ...eventParams);
+ browser.nonStartupBlocking.onEvent3.addListener(function listener2(arg) {
+ browser.test.log("listener2 called on nonStartupBlocking.onEvent3");
+ browser.test.sendMessage("listener2", arg);
+ }, ...eventParams);
+ browser.nonStartupBlocking.onEvent3.addListener(function listener3(arg) {
+ browser.test.log("listener3 called on nonStartupBlocking.onEvent3");
+ browser.test.sendMessage("listener3", arg);
+ }, ...otherEventParams);
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ // Data expected to be stored in the extension startupData with the new
+ // format and old format.
+ const STARTUP_DATA = {
+ newPersistentListenersFormat: {
+ nonStartupBlocking: {
+ onEvent3: [
+ // 2 listeners registered with the same set of extra params
+ [{ fromCustomParam1: "value1" }, ["fromCustomParam2"]],
+ [{ fromCustomParam1: "value1" }, ["fromCustomParam2"]],
+ // 1 listener registered with different set of extra params
+ [{ fromCustomParam1: "value2" }, ["fromCustomParam2Other"]],
+ ],
+ },
+ },
+ oldPersistentListenersFormat: {
+ nonStartupBlocking: {
+ onEvent3: [
+ [{ fromCustomParam1: "value1" }, ["fromCustomParam2"]],
+ [{ fromCustomParam1: "value2" }, ["fromCustomParam2Other"]],
+ ],
+ },
+ },
+ };
+
+ function getXPIStatesFilePath() {
+ let { path } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIProvider.jsm"
+ ).XPIInternal.XPIStates._jsonFile;
+ ok(
+ typeof path === "string" && !!path.length,
+ `Found XPIStates file path: ${path}`
+ );
+ return path;
+ }
+
+ async function tamperStartupData(testExtensionWrapper) {
+ const { startupData } = testExtensionWrapper.extension;
+ Assert.deepEqual(
+ startupData.persistentListeners,
+ STARTUP_DATA.newPersistentListenersFormat,
+ "Got data stored from extension.startupData.persistentListeners"
+ );
+
+ startupData.persistentListeners = STARTUP_DATA.oldPersistentListenersFormat;
+
+ // Force the data to be stored on disk (by requesting AddonTestUtils to flush
+ // the XPIStates after having tampered them to make sure they are in the
+ // format we expect from older Firefox versions).
+ testExtensionWrapper.extension.saveStartupData();
+ await AddonTestUtils.loadAddonsList(/* flush */ true);
+ const { XPIInternal } = ChromeUtils.import(
+ "resource://gre/modules/addons/XPIProvider.jsm"
+ );
+ XPIInternal.XPIStates.save();
+ await XPIInternal.XPIStates._jsonFile._save();
+ return getXPIStatesFilePath();
+ }
+
+ async function assertDiskStoredPersistentListeners(
+ extensionId,
+ xpiStatesPath,
+ expectedData
+ ) {
+ const xpiStatesData = await IOUtils.readJSON(xpiStatesPath, {
+ decompress: true,
+ });
+ const startupData =
+ xpiStatesData["app-profile"]?.addons[extensionId]?.startupData;
+ ok(startupData, `Found startupData for test extension ${extensionId}`);
+ Assert.deepEqual(
+ startupData.persistentListeners,
+ expectedData,
+ "Got the expected tampered addon startupData stored on disk"
+ );
+ }
+
+ await extension.startup();
+
+ await extension.awaitMessage("ready");
+ assertPersistentListeners(extension, "nonStartupBlocking", "onEvent3", {
+ persisted: true,
+ });
+
+ info(
+ "Manually tampering startupData.persistentListeners to match the format older Firefox format"
+ );
+ const xpiStatesFilePath = await tamperStartupData(extension);
+ await AddonTestUtils.promiseShutdownManager();
+ await assertDiskStoredPersistentListeners(
+ extension.id,
+ xpiStatesFilePath,
+ STARTUP_DATA.oldPersistentListenersFormat
+ );
+
+ info(
+ "Confirm that the expected listeners have been primed and the startupData migrated to the new format"
+ );
+
+ {
+ await AddonTestUtils.promiseStartupManager();
+ await extension.awaitStartup;
+
+ assertPersistentListeners(extension, "nonStartupBlocking", "onEvent3", {
+ primed: true,
+ // Old format of startupData.persistentListeners did not have a listenersCount
+ // property and so only two primed listeners are expected on the first startup
+ // after the addon startupData have been tampered to match the format expected
+ // by an older Firefox version.
+ primedListenersCount: 2,
+ });
+
+ const promiseListenersConverted = promiseObservable(
+ "convert-event-listener",
+ 2
+ );
+ Services.obs.notifyObservers(
+ { listenerArgs: "test-startup" },
+ "fire-nonStartupBlocking.onEvent3"
+ );
+ await promiseListenersConverted;
+
+ deepEqual(
+ await extension.awaitMessage("listener1"),
+ "test-startup",
+ "Listener1 fired for event during startup"
+ );
+
+ deepEqual(
+ await extension.awaitMessage("listener3"),
+ "test-startup",
+ "Listener3 fired for event during startup"
+ );
+
+ await extension.awaitMessage("ready");
+
+ Assert.deepEqual(
+ extension.extension.startupData.persistentListeners,
+ STARTUP_DATA.newPersistentListenersFormat,
+ "Got startupData.persistentListeners migrated to the new format"
+ );
+ }
+
+ info(
+ "Confirm that the startupData written on disk have been migrated to the new format"
+ );
+
+ await AddonTestUtils.promiseShutdownManager();
+ await assertDiskStoredPersistentListeners(
+ extension.id,
+ xpiStatesFilePath,
+ STARTUP_DATA.newPersistentListenersFormat
+ );
+
+ info(
+ "Verify that both listeners are called after migrating to the new format"
+ );
+ {
+ await AddonTestUtils.promiseStartupManager();
+ await extension.awaitStartup;
+
+ assertPersistentListeners(extension, "nonStartupBlocking", "onEvent3", {
+ primed: true,
+ primedListenersCount: 3,
+ });
+
+ const promiseListenersConverted = promiseObservable(
+ "convert-event-listener",
+ 2
+ );
+ Services.obs.notifyObservers(
+ { listenerArgs: "test-startup" },
+ "fire-nonStartupBlocking.onEvent3"
+ );
+ await promiseListenersConverted;
+
+ // Now we expect both the listeners to have been called.
+ deepEqual(
+ await extension.awaitMessage("listener1"),
+ "test-startup",
+ "Listener1 fired for event during startup"
+ );
+
+ deepEqual(
+ await extension.awaitMessage("listener2"),
+ "test-startup",
+ "Listener2 fired for event during startup"
+ );
+
+ deepEqual(
+ await extension.awaitMessage("listener3"),
+ "test-startup",
+ "Listener3 fired for event during startup"
+ );
+
+ await extension.awaitMessage("ready");
+ }
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+
+ // The additional assertions below are meant to provide a smoke test covering
+ // the behavior we would expect if an older Firefox versions (one that would
+ // expect the old format) is loading persistentListeners from startupData
+ // using the new format.
+ info("Verify backward compatibility with old format");
+
+ const { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+ );
+ const { DefaultMap } = ExtensionUtils;
+ const loadedListeners = new DefaultMap(() => new DefaultMap(() => new Map()));
+
+ // Logic from older Firefox versions expecting the old format
+ // (https://searchfox.org/mozilla-central/rev/cd2121e7d8/toolkit/components/extensions/ExtensionCommon.jsm#2360-2371)
+ let found = false;
+ for (let [module, entry] of Object.entries(
+ STARTUP_DATA.newPersistentListenersFormat
+ )) {
+ for (let [event, paramlists] of Object.entries(entry)) {
+ for (let paramlist of paramlists) {
+ let key = uneval(paramlist);
+ loadedListeners.get(module).get(event).set(key, { params: paramlist });
+ found = true;
+ }
+ }
+ }
+
+ Assert.ok(
+ found,
+ "Expect persistentListeners to have been found from the old loading logic"
+ );
+
+ // We expect the older Firefox version to don't choke on loading
+ // the new format, a primed listener is still expected to be
+ // found because the old Firefox version will be overriding a single
+ // entry in the inmemory Map with the multiple entries from the
+ // ondisk format listing the same extra params for multiple listeners,
+ // Bug 1795801 would still be hit, but no other change in behavior is
+ // expected to be hit with the old logic.
+ Assert.ok(
+ loadedListeners
+ .get("nonStartupBlocking")
+ .get("onEvent3")
+ .has(uneval([{ fromCustomParam1: "value1" }, ["fromCustomParam2"]])),
+ "Expect the listener params key to be found in older Firefox versions"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js
new file mode 100644
index 0000000000..1d5f2a8b30
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js
@@ -0,0 +1,979 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionPreferencesManager:
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs",
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+
+const { createAppInfo, promiseShutdownManager, promiseStartupManager } =
+ AddonTestUtils;
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+// Currently security.tls.version.min has a different default
+// value in Nightly and Beta as opposed to Release builds.
+const tlsMinPref = Services.prefs.getIntPref("security.tls.version.min");
+if (tlsMinPref != 1 && tlsMinPref != 3) {
+ ok(false, "This test expects security.tls.version.min set to 1 or 3.");
+}
+const tlsMinVer = tlsMinPref === 3 ? "TLSv1.2" : "TLSv1";
+const READ_ONLY = true;
+
+add_task(async function test_privacy() {
+ // Create an object to hold the values to which we will initialize the prefs.
+ const SETTINGS = {
+ "network.networkPredictionEnabled": {
+ "network.predictor.enabled": true,
+ "network.prefetch-next": true,
+ // This pref starts with a numerical value and we need to use whatever the
+ // default is or we encounter issues when the pref is reset during the test.
+ "network.http.speculative-parallel-limit":
+ ExtensionPreferencesManager.getDefaultValue(
+ "network.http.speculative-parallel-limit"
+ ),
+ "network.dns.disablePrefetch": false,
+ },
+ "websites.hyperlinkAuditingEnabled": {
+ "browser.send_pings": true,
+ },
+ };
+
+ async function background() {
+ browser.test.onMessage.addListener(async (msg, data, setting) => {
+ // The second argument is the end of the api name,
+ // e.g., "network.networkPredictionEnabled".
+ let apiObj = setting.split(".").reduce((o, i) => o[i], browser.privacy);
+ let settingData;
+ switch (msg) {
+ case "get":
+ settingData = await apiObj.get(data);
+ browser.test.sendMessage("gotData", settingData);
+ break;
+
+ case "set":
+ await apiObj.set(data);
+ settingData = await apiObj.get({});
+ browser.test.sendMessage("afterSet", settingData);
+ break;
+
+ case "clear":
+ await apiObj.clear(data);
+ settingData = await apiObj.get({});
+ browser.test.sendMessage("afterClear", settingData);
+ break;
+ }
+ });
+ }
+
+ // Set prefs to our initial values.
+ for (let setting in SETTINGS) {
+ for (let pref in SETTINGS[setting]) {
+ Preferences.set(pref, SETTINGS[setting][pref]);
+ }
+ }
+
+ registerCleanupFunction(() => {
+ // Reset the prefs.
+ for (let setting in SETTINGS) {
+ for (let pref in SETTINGS[setting]) {
+ Preferences.reset(pref);
+ }
+ }
+ });
+
+ await promiseStartupManager();
+
+ // Create an array of extensions to install.
+ let testExtensions = [
+ ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["privacy"],
+ },
+ useAddonManager: "temporary",
+ }),
+
+ ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["privacy"],
+ },
+ useAddonManager: "temporary",
+ }),
+ ];
+
+ for (let extension of testExtensions) {
+ await extension.startup();
+ }
+
+ for (let setting in SETTINGS) {
+ testExtensions[0].sendMessage("get", {}, setting);
+ let data = await testExtensions[0].awaitMessage("gotData");
+ ok(data.value, "get returns expected value.");
+ equal(
+ data.levelOfControl,
+ "controllable_by_this_extension",
+ "get returns expected levelOfControl."
+ );
+
+ testExtensions[0].sendMessage("get", { incognito: true }, setting);
+ data = await testExtensions[0].awaitMessage("gotData");
+ ok(data.value, "get returns expected value with incognito.");
+ equal(
+ data.levelOfControl,
+ "not_controllable",
+ "get returns expected levelOfControl with incognito."
+ );
+
+ // Change the value to false.
+ testExtensions[0].sendMessage("set", { value: false }, setting);
+ data = await testExtensions[0].awaitMessage("afterSet");
+ ok(!data.value, "get returns expected value after setting.");
+ equal(
+ data.levelOfControl,
+ "controlled_by_this_extension",
+ "get returns expected levelOfControl after setting."
+ );
+
+ // Verify the prefs have been set to match the "false" setting.
+ for (let pref in SETTINGS[setting]) {
+ let msg = `${pref} set correctly for ${setting}`;
+ if (pref === "network.http.speculative-parallel-limit") {
+ equal(Preferences.get(pref), 0, msg);
+ } else {
+ equal(Preferences.get(pref), !SETTINGS[setting][pref], msg);
+ }
+ }
+
+ // Change the value with a newer extension.
+ testExtensions[1].sendMessage("set", { value: true }, setting);
+ data = await testExtensions[1].awaitMessage("afterSet");
+ ok(
+ data.value,
+ "get returns expected value after setting via newer extension."
+ );
+ equal(
+ data.levelOfControl,
+ "controlled_by_this_extension",
+ "get returns expected levelOfControl after setting."
+ );
+
+ // Verify the prefs have been set to match the "true" setting.
+ for (let pref in SETTINGS[setting]) {
+ let msg = `${pref} set correctly for ${setting}`;
+ if (pref === "network.http.speculative-parallel-limit") {
+ equal(
+ Preferences.get(pref),
+ ExtensionPreferencesManager.getDefaultValue(pref),
+ msg
+ );
+ } else {
+ equal(Preferences.get(pref), SETTINGS[setting][pref], msg);
+ }
+ }
+
+ // Change the value with an older extension.
+ testExtensions[0].sendMessage("set", { value: false }, setting);
+ data = await testExtensions[0].awaitMessage("afterSet");
+ ok(data.value, "Newer extension remains in control.");
+ equal(
+ data.levelOfControl,
+ "controlled_by_other_extensions",
+ "get returns expected levelOfControl when controlled by other."
+ );
+
+ // Clear the value of the newer extension.
+ testExtensions[1].sendMessage("clear", {}, setting);
+ data = await testExtensions[1].awaitMessage("afterClear");
+ ok(!data.value, "Older extension gains control.");
+ equal(
+ data.levelOfControl,
+ "controllable_by_this_extension",
+ "Expected levelOfControl returned after clearing."
+ );
+
+ testExtensions[0].sendMessage("get", {}, setting);
+ data = await testExtensions[0].awaitMessage("gotData");
+ ok(!data.value, "Current, older extension has control.");
+ equal(
+ data.levelOfControl,
+ "controlled_by_this_extension",
+ "Expected levelOfControl returned after clearing."
+ );
+
+ // Set the value again with the newer extension.
+ testExtensions[1].sendMessage("set", { value: true }, setting);
+ data = await testExtensions[1].awaitMessage("afterSet");
+ ok(
+ data.value,
+ "get returns expected value after setting via newer extension."
+ );
+ equal(
+ data.levelOfControl,
+ "controlled_by_this_extension",
+ "get returns expected levelOfControl after setting."
+ );
+
+ // Unload the newer extension. Expect the older extension to regain control.
+ await testExtensions[1].unload();
+ testExtensions[0].sendMessage("get", {}, setting);
+ data = await testExtensions[0].awaitMessage("gotData");
+ ok(!data.value, "Older extension regained control.");
+ equal(
+ data.levelOfControl,
+ "controlled_by_this_extension",
+ "Expected levelOfControl returned after unloading."
+ );
+
+ // Reload the extension for the next iteration of the loop.
+ testExtensions[1] = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["privacy"],
+ },
+ useAddonManager: "temporary",
+ });
+ await testExtensions[1].startup();
+
+ // Clear the value of the older extension.
+ testExtensions[0].sendMessage("clear", {}, setting);
+ data = await testExtensions[0].awaitMessage("afterClear");
+ ok(data.value, "Setting returns to original value when all are cleared.");
+ equal(
+ data.levelOfControl,
+ "controllable_by_this_extension",
+ "Expected levelOfControl returned after clearing."
+ );
+
+ // Verify that our initial values were restored.
+ for (let pref in SETTINGS[setting]) {
+ equal(
+ Preferences.get(pref),
+ SETTINGS[setting][pref],
+ `${pref} was reset to its initial value.`
+ );
+ }
+ }
+
+ for (let extension of testExtensions) {
+ await extension.unload();
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_privacy_other_prefs() {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.tls.version.min");
+ Services.prefs.clearUserPref("security.tls.version.max");
+ });
+
+ const cookieSvc = Ci.nsICookieService;
+
+ // Create an object to hold the values to which we will initialize the prefs.
+ const SETTINGS = {
+ "network.webRTCIPHandlingPolicy": {
+ "media.peerconnection.ice.default_address_only": false,
+ "media.peerconnection.ice.no_host": false,
+ "media.peerconnection.ice.proxy_only_if_behind_proxy": false,
+ "media.peerconnection.ice.proxy_only": false,
+ },
+ "network.tlsVersionRestriction": {
+ "security.tls.version.min": tlsMinPref,
+ "security.tls.version.max": 4,
+ },
+ "network.peerConnectionEnabled": {
+ "media.peerconnection.enabled": true,
+ },
+ "services.passwordSavingEnabled": {
+ "signon.rememberSignons": true,
+ },
+ "websites.referrersEnabled": {
+ "network.http.sendRefererHeader": 2,
+ },
+ "websites.resistFingerprinting": {
+ "privacy.resistFingerprinting": true,
+ },
+ "websites.firstPartyIsolate": {
+ "privacy.firstparty.isolate": false,
+ },
+ "websites.cookieConfig": {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_ACCEPT,
+ },
+ };
+
+ let defaultPrefs = new Preferences({ defaultBranch: true });
+ let defaultCookieBehavior = defaultPrefs.get("network.cookie.cookieBehavior");
+ let defaultBehavior;
+ switch (defaultCookieBehavior) {
+ case cookieSvc.BEHAVIOR_ACCEPT:
+ defaultBehavior = "allow_all";
+ break;
+ case cookieSvc.BEHAVIOR_REJECT_FOREIGN:
+ defaultBehavior = "reject_third_party";
+ break;
+ case cookieSvc.BEHAVIOR_REJECT:
+ defaultBehavior = "reject_all";
+ break;
+ case cookieSvc.BEHAVIOR_LIMIT_FOREIGN:
+ defaultBehavior = "allow_visited";
+ break;
+ case cookieSvc.BEHAVIOR_REJECT_TRACKER:
+ defaultBehavior = "reject_trackers";
+ break;
+ case cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN:
+ defaultBehavior = "reject_trackers_and_partition_foreign";
+ break;
+ default:
+ ok(
+ false,
+ `Unexpected cookie behavior encountered: ${defaultCookieBehavior}`
+ );
+ break;
+ }
+
+ async function background() {
+ let listeners = new Set([]);
+ browser.test.onMessage.addListener(async (msg, data, setting, readOnly) => {
+ // The second argument is the end of the api name,
+ // e.g., "network.webRTCIPHandlingPolicy".
+ let apiObj = setting.split(".").reduce((o, i) => o[i], browser.privacy);
+ if (msg == "get") {
+ browser.test.sendMessage("gettingData", await apiObj.get({}));
+ return;
+ }
+
+ // Don't add more than one listener per apiName. We leave the
+ // listener to ensure we do not get more calls than we expect.
+ if (!listeners.has(setting)) {
+ apiObj.onChange.addListener(details => {
+ browser.test.sendMessage("settingData", details);
+ });
+ listeners.add(setting);
+ }
+ try {
+ await apiObj.set(data);
+ } catch (e) {
+ browser.test.sendMessage("settingThrowsException", {
+ message: e.message,
+ });
+ }
+ // Readonly settings will not trigger onChange, return the setting now.
+ if (readOnly) {
+ browser.test.sendMessage("settingData", await apiObj.get({}));
+ }
+ });
+ }
+
+ // Set prefs to our initial values.
+ for (let setting in SETTINGS) {
+ for (let pref in SETTINGS[setting]) {
+ Preferences.set(pref, SETTINGS[setting][pref]);
+ }
+ }
+
+ registerCleanupFunction(() => {
+ // Reset the prefs.
+ for (let setting in SETTINGS) {
+ for (let pref in SETTINGS[setting]) {
+ Preferences.reset(pref);
+ }
+ }
+ });
+
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["privacy"],
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ async function testSetting(setting, value, expected, expectedValue = value) {
+ extension.sendMessage("set", { value: value }, setting);
+ let data = await extension.awaitMessage("settingData");
+ deepEqual(
+ data.value,
+ expectedValue,
+ `Got expected result on setting ${setting} to ${uneval(value)}`
+ );
+ for (let pref in expected) {
+ equal(
+ Preferences.get(pref),
+ expected[pref],
+ `${pref} set correctly for ${expected[pref]}`
+ );
+ }
+ }
+
+ async function testSettingException(setting, value, expected) {
+ extension.sendMessage("set", { value: value }, setting);
+ let data = await extension.awaitMessage("settingThrowsException");
+ equal(data.message, expected);
+ }
+
+ async function testGetting(getting, expected, expectedValue) {
+ extension.sendMessage("get", null, getting);
+ let data = await extension.awaitMessage("gettingData");
+ deepEqual(
+ data.value,
+ expectedValue,
+ `Got expected result on getting ${getting}`
+ );
+ for (let pref in expected) {
+ equal(
+ Preferences.get(pref),
+ expected[pref],
+ `${pref} get correctly for ${expected[pref]}`
+ );
+ }
+ }
+
+ await testSetting(
+ "network.webRTCIPHandlingPolicy",
+ "default_public_and_private_interfaces",
+ {
+ "media.peerconnection.ice.default_address_only": true,
+ "media.peerconnection.ice.no_host": false,
+ "media.peerconnection.ice.proxy_only_if_behind_proxy": false,
+ "media.peerconnection.ice.proxy_only": false,
+ }
+ );
+ await testSetting(
+ "network.webRTCIPHandlingPolicy",
+ "default_public_interface_only",
+ {
+ "media.peerconnection.ice.default_address_only": true,
+ "media.peerconnection.ice.no_host": true,
+ "media.peerconnection.ice.proxy_only_if_behind_proxy": false,
+ "media.peerconnection.ice.proxy_only": false,
+ }
+ );
+ await testSetting(
+ "network.webRTCIPHandlingPolicy",
+ "disable_non_proxied_udp",
+ {
+ "media.peerconnection.ice.default_address_only": true,
+ "media.peerconnection.ice.no_host": true,
+ "media.peerconnection.ice.proxy_only_if_behind_proxy": true,
+ "media.peerconnection.ice.proxy_only": false,
+ }
+ );
+ await testSetting("network.webRTCIPHandlingPolicy", "proxy_only", {
+ "media.peerconnection.ice.default_address_only": false,
+ "media.peerconnection.ice.no_host": false,
+ "media.peerconnection.ice.proxy_only_if_behind_proxy": false,
+ "media.peerconnection.ice.proxy_only": true,
+ });
+ await testSetting("network.webRTCIPHandlingPolicy", "default", {
+ "media.peerconnection.ice.default_address_only": false,
+ "media.peerconnection.ice.no_host": false,
+ "media.peerconnection.ice.proxy_only_if_behind_proxy": false,
+ "media.peerconnection.ice.proxy_only": false,
+ });
+
+ await testSetting("network.peerConnectionEnabled", false, {
+ "media.peerconnection.enabled": false,
+ });
+ await testSetting("network.peerConnectionEnabled", true, {
+ "media.peerconnection.enabled": true,
+ });
+
+ await testSetting("websites.referrersEnabled", false, {
+ "network.http.sendRefererHeader": 0,
+ });
+ await testSetting("websites.referrersEnabled", true, {
+ "network.http.sendRefererHeader": 2,
+ });
+
+ await testSetting("websites.resistFingerprinting", false, {
+ "privacy.resistFingerprinting": false,
+ });
+ await testSetting("websites.resistFingerprinting", true, {
+ "privacy.resistFingerprinting": true,
+ });
+
+ await testSetting("websites.trackingProtectionMode", "always", {
+ "privacy.trackingprotection.enabled": true,
+ "privacy.trackingprotection.pbmode.enabled": true,
+ });
+ await testSetting("websites.trackingProtectionMode", "never", {
+ "privacy.trackingprotection.enabled": false,
+ "privacy.trackingprotection.pbmode.enabled": false,
+ });
+ await testSetting("websites.trackingProtectionMode", "private_browsing", {
+ "privacy.trackingprotection.enabled": false,
+ "privacy.trackingprotection.pbmode.enabled": true,
+ });
+
+ await testSetting("services.passwordSavingEnabled", false, {
+ "signon.rememberSignons": false,
+ });
+ await testSetting("services.passwordSavingEnabled", true, {
+ "signon.rememberSignons": true,
+ });
+
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_third_party", nonPersistentCookies: true },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_FOREIGN,
+ },
+ { behavior: "reject_third_party", nonPersistentCookies: false }
+ );
+ // A missing nonPersistentCookies property should default to false.
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_third_party" },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_FOREIGN,
+ },
+ { behavior: "reject_third_party", nonPersistentCookies: false }
+ );
+ // A missing behavior property should reset the pref.
+ await testSetting(
+ "websites.cookieConfig",
+ { nonPersistentCookies: true },
+ {
+ "network.cookie.cookieBehavior": defaultCookieBehavior,
+ },
+ { behavior: defaultBehavior, nonPersistentCookies: false }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_all" },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT,
+ },
+ { behavior: "reject_all", nonPersistentCookies: false }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "allow_visited" },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_LIMIT_FOREIGN,
+ },
+ { behavior: "allow_visited", nonPersistentCookies: false }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "allow_all" },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_ACCEPT,
+ },
+ { behavior: "allow_all", nonPersistentCookies: false }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { nonPersistentCookies: true },
+ {
+ "network.cookie.cookieBehavior": defaultCookieBehavior,
+ },
+ { behavior: defaultBehavior, nonPersistentCookies: false }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { nonPersistentCookies: false },
+ {
+ "network.cookie.cookieBehavior": defaultCookieBehavior,
+ },
+ { behavior: defaultBehavior, nonPersistentCookies: false }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_trackers" },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_TRACKER,
+ },
+ { behavior: "reject_trackers", nonPersistentCookies: false }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_trackers_and_partition_foreign" },
+ {
+ "network.cookie.cookieBehavior":
+ cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN,
+ },
+ {
+ behavior: "reject_trackers_and_partition_foreign",
+ nonPersistentCookies: false,
+ }
+ );
+
+ // 1. Can't enable FPI when cookie behavior is "reject_trackers_and_partition_foreign"
+ await testSettingException(
+ "websites.firstPartyIsolate",
+ true,
+ "Can't enable firstPartyIsolate when cookieBehavior is 'reject_trackers_and_partition_foreign'"
+ );
+
+ // 2. Change cookieConfig to reject_trackers should work normally.
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_trackers" },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_TRACKER,
+ },
+ { behavior: "reject_trackers", nonPersistentCookies: false }
+ );
+
+ // 3. Enable FPI
+ await testSetting("websites.firstPartyIsolate", true, {
+ "privacy.firstparty.isolate": true,
+ });
+
+ // 4. When FPI is enabled, change setting to "reject_trackers_and_partition_foreign" is invalid
+ await testSettingException(
+ "websites.cookieConfig",
+ { behavior: "reject_trackers_and_partition_foreign" },
+ "Invalid cookieConfig 'reject_trackers_and_partition_foreign' when firstPartyIsolate is enabled"
+ );
+
+ // 5. Set conflict settings manually and check prefs.
+ Preferences.set("network.cookie.cookieBehavior", 5);
+ await testGetting(
+ "websites.firstPartyIsolate",
+ { "privacy.firstparty.isolate": true },
+ true
+ );
+ await testGetting(
+ "websites.cookieConfig",
+ {
+ "network.cookie.cookieBehavior":
+ cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN,
+ },
+ {
+ behavior: "reject_trackers_and_partition_foreign",
+ nonPersistentCookies: false,
+ }
+ );
+
+ // 6. It is okay to set current saved value.
+ await testSetting("websites.firstPartyIsolate", true, {
+ "privacy.firstparty.isolate": true,
+ });
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_trackers_and_partition_foreign" },
+ {
+ "network.cookie.cookieBehavior":
+ cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN,
+ },
+ {
+ behavior: "reject_trackers_and_partition_foreign",
+ nonPersistentCookies: false,
+ }
+ );
+
+ await testSetting("websites.firstPartyIsolate", false, {
+ "privacy.firstparty.isolate": false,
+ });
+
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.2",
+ maximum: "TLSv1.3",
+ },
+ {
+ "security.tls.version.min": 3,
+ "security.tls.version.max": 4,
+ }
+ );
+
+ // Single values
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.3",
+ },
+ {
+ "security.tls.version.min": 4,
+ "security.tls.version.max": 4,
+ },
+ {
+ minimum: "TLSv1.3",
+ maximum: "TLSv1.3",
+ }
+ );
+
+ // Single values
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.3",
+ },
+ {
+ "security.tls.version.min": 4,
+ "security.tls.version.max": 4,
+ },
+ {
+ minimum: "TLSv1.3",
+ maximum: "TLSv1.3",
+ }
+ );
+
+ // Invalid values.
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "invalid",
+ maximum: "invalid",
+ },
+ "Setting TLS version invalid is not allowed for security reasons."
+ );
+
+ // Invalid values.
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "invalid2",
+ },
+ "Setting TLS version invalid2 is not allowed for security reasons."
+ );
+
+ // Invalid values.
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ maximum: "invalid3",
+ },
+ "Setting TLS version invalid3 is not allowed for security reasons."
+ );
+
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.2",
+ },
+ {
+ "security.tls.version.min": 3,
+ "security.tls.version.max": 4,
+ },
+ {
+ minimum: "TLSv1.2",
+ maximum: "TLSv1.3",
+ }
+ );
+
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ maximum: "TLSv1.2",
+ },
+ {
+ "security.tls.version.min": tlsMinPref,
+ "security.tls.version.max": 3,
+ },
+ {
+ minimum: tlsMinVer,
+ maximum: "TLSv1.2",
+ }
+ );
+
+ // Not supported version.
+ if (tlsMinPref === 3) {
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1",
+ },
+ "Setting TLS version TLSv1 is not allowed for security reasons."
+ );
+
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.1",
+ },
+ "Setting TLS version TLSv1.1 is not allowed for security reasons."
+ );
+
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ maximum: "TLSv1",
+ },
+ "Setting TLS version TLSv1 is not allowed for security reasons."
+ );
+
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ maximum: "TLSv1.1",
+ },
+ "Setting TLS version TLSv1.1 is not allowed for security reasons."
+ );
+ }
+
+ // Min vs Max
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.3",
+ maximum: "TLSv1.2",
+ },
+ "Setting TLS min version grater than the max version is not allowed."
+ );
+
+ // Min vs Max (with default max)
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.2",
+ maximum: "TLSv1.2",
+ },
+ {
+ "security.tls.version.min": 3,
+ "security.tls.version.max": 3,
+ }
+ );
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.3",
+ },
+ "Setting TLS min version grater than the max version is not allowed."
+ );
+
+ // Max vs Min
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.3",
+ maximum: "TLSv1.3",
+ },
+ {
+ "security.tls.version.min": 4,
+ "security.tls.version.max": 4,
+ }
+ );
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ maximum: "TLSv1.2",
+ },
+ "Setting TLS max version lower than the min version is not allowed."
+ );
+
+ // Empty value.
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {},
+ {
+ "security.tls.version.min": tlsMinPref,
+ "security.tls.version.max": 4,
+ },
+ {
+ minimum: tlsMinVer,
+ maximum: "TLSv1.3",
+ }
+ );
+
+ const GLOBAL_PRIVACY_CONTROL_PREF_NAME =
+ "privacy.globalprivacycontrol.enabled";
+
+ Preferences.set(GLOBAL_PRIVACY_CONTROL_PREF_NAME, false);
+ await testGetting("network.globalPrivacyControl", {}, false);
+
+ Preferences.set(GLOBAL_PRIVACY_CONTROL_PREF_NAME, true);
+ await testGetting("network.globalPrivacyControl", {}, true);
+
+ // trying to "set" should have no effect when readonly!
+ extension.sendMessage(
+ "set",
+ { value: !Preferences.get(GLOBAL_PRIVACY_CONTROL_PREF_NAME) },
+ "network.globalPrivacyControl",
+ READ_ONLY
+ );
+ let readOnlyGPCData = await extension.awaitMessage("settingData");
+ equal(
+ readOnlyGPCData.value,
+ Preferences.get(GLOBAL_PRIVACY_CONTROL_PREF_NAME),
+ "extension cannot set globalPrivacyControl"
+ );
+
+ equal(Preferences.get(GLOBAL_PRIVACY_CONTROL_PREF_NAME), true);
+
+ const HTTPS_ONLY_PREF_NAME = "dom.security.https_only_mode";
+ const HTTPS_ONLY_PBM_PREF_NAME = "dom.security.https_only_mode_pbm";
+
+ Preferences.set(HTTPS_ONLY_PREF_NAME, false);
+ Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, false);
+ await testGetting("network.httpsOnlyMode", {}, "never");
+
+ Preferences.set(HTTPS_ONLY_PREF_NAME, true);
+ Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, false);
+ await testGetting("network.httpsOnlyMode", {}, "always");
+
+ Preferences.set(HTTPS_ONLY_PREF_NAME, false);
+ Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, true);
+ await testGetting("network.httpsOnlyMode", {}, "private_browsing");
+
+ // Please note that if https_only_mode = true, then
+ // https_only_mode_pbm has no effect.
+ Preferences.set(HTTPS_ONLY_PREF_NAME, true);
+ Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, true);
+ await testGetting("network.httpsOnlyMode", {}, "always");
+
+ // trying to "set" should have no effect when readonly!
+ extension.sendMessage(
+ "set",
+ { value: "never" },
+ "network.httpsOnlyMode",
+ READ_ONLY
+ );
+ let readOnlyData = await extension.awaitMessage("settingData");
+ equal(readOnlyData.value, "always");
+
+ equal(Preferences.get(HTTPS_ONLY_PREF_NAME), true);
+ equal(Preferences.get(HTTPS_ONLY_PBM_PREF_NAME), true);
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_exceptions() {
+ async function background() {
+ await browser.test.assertRejects(
+ browser.privacy.network.networkPredictionEnabled.set({
+ value: true,
+ scope: "regular_only",
+ }),
+ "Firefox does not support the regular_only settings scope.",
+ "Expected rejection calling set with invalid scope."
+ );
+
+ await browser.test.assertRejects(
+ browser.privacy.network.networkPredictionEnabled.clear({
+ scope: "incognito_persistent",
+ }),
+ "Firefox does not support the incognito_persistent settings scope.",
+ "Expected rejection calling clear with invalid scope."
+ );
+
+ browser.test.notifyPass("exceptionTests");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["privacy"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("exceptionTests");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js
new file mode 100644
index 0000000000..637751f473
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js
@@ -0,0 +1,180 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ ExtensionPreferencesManager:
+ "resource://gre/modules/ExtensionPreferencesManager.sys.mjs",
+ Management: "resource://gre/modules/Extension.sys.mjs",
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+
+const { createAppInfo, promiseShutdownManager, promiseStartupManager } =
+ AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+function awaitEvent(eventName) {
+ return new Promise(resolve => {
+ let listener = (_eventName, ...args) => {
+ if (_eventName === eventName) {
+ Management.off(eventName, listener);
+ resolve(...args);
+ }
+ };
+
+ Management.on(eventName, listener);
+ });
+}
+
+function awaitPrefChange(prefName) {
+ return new Promise(resolve => {
+ let listener = args => {
+ Preferences.ignore(prefName, listener);
+ resolve();
+ };
+
+ Preferences.observe(prefName, listener);
+ });
+}
+
+add_task(async function test_disable() {
+ const OLD_ID = "old_id@tests.mozilla.org";
+ const NEW_ID = "new_id@tests.mozilla.org";
+
+ const PREF_TO_WATCH = "network.http.speculative-parallel-limit";
+
+ // Create an object to hold the values to which we will initialize the prefs.
+ const PREFS = {
+ "network.predictor.enabled": true,
+ "network.prefetch-next": true,
+ "network.http.speculative-parallel-limit": 10,
+ "network.dns.disablePrefetch": false,
+ };
+
+ // Set prefs to our initial values.
+ for (let pref in PREFS) {
+ Preferences.set(pref, PREFS[pref]);
+ }
+
+ registerCleanupFunction(() => {
+ // Reset the prefs.
+ for (let pref in PREFS) {
+ Preferences.reset(pref);
+ }
+ });
+
+ function checkPrefs(expected) {
+ for (let pref in PREFS) {
+ let msg = `${pref} set correctly.`;
+ let expectedValue = expected ? PREFS[pref] : !PREFS[pref];
+ if (pref === "network.http.speculative-parallel-limit") {
+ expectedValue = expected
+ ? ExtensionPreferencesManager.getDefaultValue(pref)
+ : 0;
+ }
+ equal(Preferences.get(pref), expectedValue, msg);
+ }
+ }
+
+ async function background() {
+ browser.test.onMessage.addListener(async (msg, data) => {
+ await browser.privacy.network.networkPredictionEnabled.set(data);
+ let settingData =
+ await browser.privacy.network.networkPredictionEnabled.get({});
+ browser.test.sendMessage("privacyData", settingData);
+ });
+ }
+
+ await promiseStartupManager();
+
+ let testExtensions = [
+ ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: OLD_ID,
+ },
+ },
+ permissions: ["privacy"],
+ },
+ useAddonManager: "temporary",
+ }),
+
+ ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: NEW_ID,
+ },
+ },
+ permissions: ["privacy"],
+ },
+ useAddonManager: "temporary",
+ }),
+ ];
+
+ for (let extension of testExtensions) {
+ await extension.startup();
+ }
+
+ // Set the value to true for the older extension.
+ testExtensions[0].sendMessage("set", { value: true });
+ let data = await testExtensions[0].awaitMessage("privacyData");
+ ok(data.value, "Value set to true for the older extension.");
+
+ // Set the value to false for the newest extension.
+ testExtensions[1].sendMessage("set", { value: false });
+ data = await testExtensions[1].awaitMessage("privacyData");
+ ok(!data.value, "Value set to false for the newest extension.");
+
+ // Verify the prefs have been set to match the "false" setting.
+ checkPrefs(false);
+
+ // Disable the newest extension.
+ let disabledPromise = awaitPrefChange(PREF_TO_WATCH);
+ let newAddon = await AddonManager.getAddonByID(NEW_ID);
+ await newAddon.disable();
+ await disabledPromise;
+
+ // Verify the prefs have been set to match the "true" setting.
+ checkPrefs(true);
+
+ // Disable the older extension.
+ disabledPromise = awaitPrefChange(PREF_TO_WATCH);
+ let oldAddon = await AddonManager.getAddonByID(OLD_ID);
+ await oldAddon.disable();
+ await disabledPromise;
+
+ // Verify the prefs have reverted back to their initial values.
+ for (let pref in PREFS) {
+ equal(Preferences.get(pref), PREFS[pref], `${pref} reset correctly.`);
+ }
+
+ // Re-enable the newest extension.
+ let enabledPromise = awaitEvent("ready");
+ await newAddon.enable();
+ await enabledPromise;
+
+ // Verify the prefs have been set to match the "false" setting.
+ checkPrefs(false);
+
+ // Re-enable the older extension.
+ enabledPromise = awaitEvent("ready");
+ await oldAddon.enable();
+ await enabledPromise;
+
+ // Verify the prefs have remained set to match the "false" setting.
+ checkPrefs(false);
+
+ for (let extension of testExtensions) {
+ await extension.unload();
+ }
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_nonPersistentCookies.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_nonPersistentCookies.js
new file mode 100644
index 0000000000..91f0feaa82
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_nonPersistentCookies.js
@@ -0,0 +1,54 @@
+"use strict";
+
+const { promiseShutdownManager, promiseStartupManager } = AddonTestUtils;
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_task(async function test_nonPersistentCookies_is_deprecated() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["privacy"],
+ },
+ useAddonManager: "temporary",
+ async background() {
+ for (const nonPersistentCookies of [true, false]) {
+ await browser.privacy.websites.cookieConfig.set({
+ value: {
+ behavior: "reject_third_party",
+ nonPersistentCookies,
+ },
+ });
+ }
+
+ browser.test.sendMessage("background-done");
+ },
+ });
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+ await extension.unload();
+ });
+
+ const expectedMessage =
+ /"'nonPersistentCookies' has been deprecated and it has no effect anymore."/;
+
+ AddonTestUtils.checkMessages(
+ messages,
+ {
+ expected: [{ message: expectedMessage }, { message: expectedMessage }],
+ },
+ true
+ );
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js
new file mode 100644
index 0000000000..5dcbca9d63
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js
@@ -0,0 +1,163 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+
+const {
+ createAppInfo,
+ createTempWebExtensionFile,
+ promiseCompleteAllInstalls,
+ promiseFindAddonUpdates,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+// Allow for unsigned addons.
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+
+add_task(async function test_privacy_update() {
+ // Create a object to hold the values to which we will initialize the prefs.
+ const PREFS = {
+ "network.predictor.enabled": true,
+ "network.prefetch-next": true,
+ "network.http.speculative-parallel-limit": 10,
+ "network.dns.disablePrefetch": false,
+ };
+
+ const EXTENSION_ID = "test_privacy_addon_update@tests.mozilla.org";
+ const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
+
+ // Set prefs to our initial values.
+ for (let pref in PREFS) {
+ Preferences.set(pref, PREFS[pref]);
+ }
+
+ registerCleanupFunction(() => {
+ // Reset the prefs.
+ for (let pref in PREFS) {
+ Preferences.reset(pref);
+ }
+ });
+
+ async function background() {
+ browser.test.onMessage.addListener(async (msg, data) => {
+ let settingData;
+ switch (msg) {
+ case "get":
+ settingData =
+ await browser.privacy.network.networkPredictionEnabled.get({});
+ browser.test.sendMessage("privacyData", settingData);
+ break;
+
+ case "set":
+ await browser.privacy.network.networkPredictionEnabled.set(data);
+ settingData =
+ await browser.privacy.network.networkPredictionEnabled.get({});
+ browser.test.sendMessage("privacyData", settingData);
+ break;
+ }
+ });
+ }
+
+ const testServer = createHttpServer();
+ const port = testServer.identity.primaryPort;
+
+ // The test extension uses an insecure update url.
+ Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+ testServer.registerPathHandler("/test_update.json", (request, response) => {
+ response.write(`{
+ "addons": {
+ "${EXTENSION_ID}": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://localhost:${port}/addons/test_privacy-2.0.xpi"
+ }
+ ]
+ }
+ }
+ }`);
+ });
+
+ let webExtensionFile = createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ permissions: ["privacy"],
+ },
+ background,
+ });
+
+ testServer.registerFile("/addons/test_privacy-2.0.xpi", webExtensionFile);
+
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ update_url: `http://localhost:${port}/test_update.json`,
+ },
+ },
+ permissions: ["privacy"],
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ // Change the value to false.
+ extension.sendMessage("set", { value: false });
+ let data = await extension.awaitMessage("privacyData");
+ ok(!data.value, "get returns expected value after setting.");
+
+ equal(
+ extension.version,
+ "1.0",
+ "The installed addon has the expected version."
+ );
+
+ let update = await promiseFindAddonUpdates(extension.addon);
+ let install = update.updateAvailable;
+
+ await promiseCompleteAllInstalls([install]);
+
+ await extension.awaitStartup();
+
+ equal(
+ extension.version,
+ "2.0",
+ "The updated addon has the expected version."
+ );
+
+ extension.sendMessage("get");
+ data = await extension.awaitMessage("privacyData");
+ ok(!data.value, "get returns expected value after updating.");
+
+ // Verify the prefs are still set to match the "false" setting.
+ for (let pref in PREFS) {
+ let msg = `${pref} set correctly.`;
+ let expectedValue =
+ pref === "network.http.speculative-parallel-limit" ? 0 : !PREFS[pref];
+ equal(Preferences.get(pref), expectedValue, msg);
+ }
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js
new file mode 100644
index 0000000000..27f537b73b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js
@@ -0,0 +1,116 @@
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "authManager",
+ "@mozilla.org/network/http-auth-manager;1",
+ "nsIHttpAuthManager"
+);
+
+const proxy = createHttpServer();
+const proxyToken = "this_is_my_pass";
+
+// accept proxy connections for mozilla.org
+proxy.identity.add("http", "mozilla.org", 80);
+
+proxy.registerPathHandler("/", (request, response) => {
+ if (request.hasHeader("Proxy-Authorization")) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/plain", false);
+ response.write(request.getHeader("Proxy-Authorization"));
+ } else {
+ response.setStatusLine(
+ request.httpVersion,
+ 407,
+ "Proxy authentication required"
+ );
+ response.setHeader("Content-Type", "text/plain", false);
+ response.setHeader("Proxy-Authenticate", "UnknownMeantToFail", false);
+ response.write("auth required");
+ }
+});
+
+function getExtension(background) {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background: `(${background})(${proxy.identity.primaryPort}, "${proxyToken}")`,
+ });
+}
+
+add_task(async function test_webRequest_auth_proxy() {
+ function background(port, proxyToken) {
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.log(`onCompleted ${JSON.stringify(details)}\n`);
+ browser.test.assertEq(
+ "localhost",
+ details.proxyInfo.host,
+ "proxy host"
+ );
+ browser.test.assertEq(port, details.proxyInfo.port, "proxy port");
+ browser.test.assertEq("http", details.proxyInfo.type, "proxy type");
+ browser.test.assertEq(
+ "",
+ details.proxyInfo.username,
+ "proxy username not set"
+ );
+ browser.test.assertEq(
+ proxyToken,
+ details.proxyInfo.proxyAuthorizationHeader,
+ "proxy authorization header"
+ );
+ browser.test.assertEq(
+ proxyToken,
+ details.proxyInfo.connectionIsolationKey,
+ "proxy connection isolation"
+ );
+
+ browser.test.notifyPass("requestCompleted");
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ // Using proxyAuthorizationHeader should prevent an auth request coming to us in the extension.
+ browser.test.fail("onAuthRequired");
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+
+ // Handle the proxy request.
+ browser.proxy.onRequest.addListener(
+ details => {
+ browser.test.log(`onRequest ${JSON.stringify(details)}`);
+ return [
+ {
+ host: "localhost",
+ port,
+ type: "http",
+ proxyAuthorizationHeader: proxyToken,
+ connectionIsolationKey: proxyToken,
+ },
+ ];
+ },
+ { urls: ["<all_urls>"] },
+ ["requestHeaders"]
+ );
+ }
+
+ let extension = getExtension(background);
+
+ await extension.startup();
+
+ authManager.clearAll();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `http://mozilla.org/`
+ );
+
+ await extension.awaitFinish("requestCompleted");
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js
new file mode 100644
index 0000000000..490de51d3a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js
@@ -0,0 +1,614 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+// Start a server for `pac.example.com` to intercept attempts to connect to it
+// to load a PAC URL. We won't serve anything, but this prevents attempts at
+// non-local connections if this domain is registered.
+AddonTestUtils.createHttpServer({ hosts: ["pac.example.com"] });
+
+add_task(async function setup() {
+ // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy
+ // storage mode will run in xpcshell-legacy-ep.ini
+ await ExtensionPermissions._uninit();
+
+ Services.prefs.setBoolPref(
+ "extensions.webextOptionalPermissionPrompts",
+ false
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts");
+ });
+
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_browser_settings() {
+ const proxySvc = Ci.nsIProtocolProxyService;
+
+ // Create an object to hold the values to which we will initialize the prefs.
+ const PREFS = {
+ "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM,
+ "network.proxy.http": "",
+ "network.proxy.http_port": 0,
+ "network.proxy.share_proxy_settings": false,
+ "network.proxy.ssl": "",
+ "network.proxy.ssl_port": 0,
+ "network.proxy.socks": "",
+ "network.proxy.socks_port": 0,
+ "network.proxy.socks_version": 5,
+ "network.proxy.socks_remote_dns": false,
+ "network.proxy.no_proxies_on": "",
+ "network.proxy.autoconfig_url": "",
+ "signon.autologin.proxy": false,
+ };
+
+ async function background() {
+ browser.test.onMessage.addListener(async (msg, value) => {
+ let apiObj = browser.proxy.settings;
+ let result = await apiObj.set({ value });
+ if (msg === "set") {
+ browser.test.assertTrue(result, "set returns true.");
+ browser.test.sendMessage("settingData", await apiObj.get({}));
+ } else {
+ browser.test.assertFalse(result, "set returns false for a no-op.");
+ browser.test.sendMessage("no-op set");
+ }
+ });
+ }
+
+ // Set prefs to our initial values.
+ for (let pref in PREFS) {
+ Preferences.set(pref, PREFS[pref]);
+ }
+
+ registerCleanupFunction(() => {
+ // Reset the prefs.
+ for (let pref in PREFS) {
+ Preferences.reset(pref);
+ }
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["proxy"],
+ },
+ incognitoOverride: "spanning",
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ async function testSetting(value, expected, expectedValue = value) {
+ extension.sendMessage("set", value);
+ let data = await extension.awaitMessage("settingData");
+ deepEqual(data.value, expectedValue, `The setting has the expected value.`);
+ equal(
+ data.levelOfControl,
+ "controlled_by_this_extension",
+ `The setting has the expected levelOfControl.`
+ );
+ for (let pref in expected) {
+ equal(
+ Preferences.get(pref),
+ expected[pref],
+ `${pref} set correctly for ${value}`
+ );
+ }
+ }
+
+ async function testProxy(config, expectedPrefs, expectedConfig = config) {
+ // proxy.settings is not supported on Android.
+ if (AppConstants.platform === "android") {
+ return Promise.resolve();
+ }
+
+ let proxyConfig = {
+ proxyType: "none",
+ autoConfigUrl: "",
+ autoLogin: false,
+ proxyDNS: false,
+ httpProxyAll: false,
+ socksVersion: 5,
+ passthrough: "",
+ http: "",
+ ssl: "",
+ socks: "",
+ respectBeConservative: true,
+ };
+
+ expectedConfig.proxyType = expectedConfig.proxyType || "system";
+
+ return testSetting(
+ config,
+ expectedPrefs,
+ Object.assign(proxyConfig, expectedConfig)
+ );
+ }
+
+ await testProxy(
+ { proxyType: "none" },
+ { "network.proxy.type": proxySvc.PROXYCONFIG_DIRECT }
+ );
+
+ await testProxy(
+ {
+ proxyType: "autoDetect",
+ autoLogin: true,
+ proxyDNS: true,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_WPAD,
+ "signon.autologin.proxy": true,
+ "network.proxy.socks_remote_dns": true,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "system",
+ autoLogin: false,
+ proxyDNS: false,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM,
+ "signon.autologin.proxy": false,
+ "network.proxy.socks_remote_dns": false,
+ }
+ );
+
+ // Verify that proxyType is optional and it defaults to "system".
+ await testProxy(
+ {
+ autoLogin: false,
+ proxyDNS: false,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM,
+ "signon.autologin.proxy": false,
+ "network.proxy.socks_remote_dns": false,
+ "network.http.proxy.respect-be-conservative": true,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "autoConfig",
+ autoConfigUrl: "http://pac.example.com",
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_PAC,
+ "network.proxy.autoconfig_url": "http://pac.example.com",
+ "network.http.proxy.respect-be-conservative": true,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "manual",
+ http: "http://www.mozilla.org",
+ autoConfigUrl: "",
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+ "network.proxy.http": "www.mozilla.org",
+ "network.proxy.http_port": 80,
+ "network.proxy.autoconfig_url": "",
+ },
+ {
+ proxyType: "manual",
+ http: "www.mozilla.org:80",
+ autoConfigUrl: "",
+ }
+ );
+
+ // When using proxyAll, we expect all proxies to be set to
+ // be the same as http.
+ await testProxy(
+ {
+ proxyType: "manual",
+ http: "http://www.mozilla.org:8080",
+ httpProxyAll: true,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+ "network.proxy.http": "www.mozilla.org",
+ "network.proxy.http_port": 8080,
+ "network.proxy.ssl": "www.mozilla.org",
+ "network.proxy.ssl_port": 8080,
+ "network.proxy.share_proxy_settings": true,
+ },
+ {
+ proxyType: "manual",
+ http: "www.mozilla.org:8080",
+ ssl: "www.mozilla.org:8080",
+ socks: "",
+ httpProxyAll: true,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "manual",
+ http: "www.mozilla.org:8080",
+ httpProxyAll: false,
+ ftp: "www.mozilla.org:8081",
+ ssl: "www.mozilla.org:8082",
+ socks: "mozilla.org:8083",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: true,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+ "network.proxy.http": "www.mozilla.org",
+ "network.proxy.http_port": 8080,
+ "network.proxy.share_proxy_settings": false,
+ "network.proxy.ssl": "www.mozilla.org",
+ "network.proxy.ssl_port": 8082,
+ "network.proxy.socks": "mozilla.org",
+ "network.proxy.socks_port": 8083,
+ "network.proxy.socks_version": 4,
+ "network.proxy.no_proxies_on": ".mozilla.org",
+ "network.http.proxy.respect-be-conservative": true,
+ },
+ {
+ proxyType: "manual",
+ http: "www.mozilla.org:8080",
+ httpProxyAll: false,
+ // ftp: "www.mozilla.org:8081", // This line should not be sent back
+ ssl: "www.mozilla.org:8082",
+ socks: "mozilla.org:8083",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: true,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "manual",
+ http: "http://www.mozilla.org",
+ ssl: "https://www.mozilla.org",
+ socks: "mozilla.org",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: false,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+ "network.proxy.http": "www.mozilla.org",
+ "network.proxy.http_port": 80,
+ "network.proxy.share_proxy_settings": false,
+ "network.proxy.ssl": "www.mozilla.org",
+ "network.proxy.ssl_port": 443,
+ "network.proxy.socks": "mozilla.org",
+ "network.proxy.socks_port": 1080,
+ "network.proxy.socks_version": 4,
+ "network.proxy.no_proxies_on": ".mozilla.org",
+ "network.http.proxy.respect-be-conservative": false,
+ },
+ {
+ proxyType: "manual",
+ http: "www.mozilla.org:80",
+ httpProxyAll: false,
+ ssl: "www.mozilla.org:443",
+ socks: "mozilla.org:1080",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: false,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "manual",
+ http: "http://www.mozilla.org:80",
+ ssl: "https://www.mozilla.org:443",
+ socks: "mozilla.org:1080",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: true,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+ "network.proxy.http": "www.mozilla.org",
+ "network.proxy.http_port": 80,
+ "network.proxy.share_proxy_settings": false,
+ "network.proxy.ssl": "www.mozilla.org",
+ "network.proxy.ssl_port": 443,
+ "network.proxy.socks": "mozilla.org",
+ "network.proxy.socks_port": 1080,
+ "network.proxy.socks_version": 4,
+ "network.proxy.no_proxies_on": ".mozilla.org",
+ "network.http.proxy.respect-be-conservative": true,
+ },
+ {
+ proxyType: "manual",
+ http: "www.mozilla.org:80",
+ httpProxyAll: false,
+ ssl: "www.mozilla.org:443",
+ socks: "mozilla.org:1080",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: true,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "manual",
+ http: "http://www.mozilla.org:80",
+ ssl: "https://www.mozilla.org:80",
+ socks: "mozilla.org:80",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: false,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+ "network.proxy.http": "www.mozilla.org",
+ "network.proxy.http_port": 80,
+ "network.proxy.share_proxy_settings": false,
+ "network.proxy.ssl": "www.mozilla.org",
+ "network.proxy.ssl_port": 80,
+ "network.proxy.socks": "mozilla.org",
+ "network.proxy.socks_port": 80,
+ "network.proxy.socks_version": 4,
+ "network.proxy.no_proxies_on": ".mozilla.org",
+ "network.http.proxy.respect-be-conservative": false,
+ },
+ {
+ proxyType: "manual",
+ http: "www.mozilla.org:80",
+ httpProxyAll: false,
+ ssl: "www.mozilla.org:80",
+ socks: "mozilla.org:80",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: false,
+ }
+ );
+
+ // Test resetting values.
+ await testProxy(
+ {
+ proxyType: "none",
+ http: "",
+ ssl: "",
+ socks: "",
+ socksVersion: 5,
+ passthrough: "",
+ respectBeConservative: true,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_DIRECT,
+ "network.proxy.http": "",
+ "network.proxy.http_port": 0,
+ "network.proxy.ssl": "",
+ "network.proxy.ssl_port": 0,
+ "network.proxy.socks": "",
+ "network.proxy.socks_port": 0,
+ "network.proxy.socks_version": 5,
+ "network.proxy.no_proxies_on": "",
+ "network.http.proxy.respect-be-conservative": true,
+ }
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_bad_value_proxy_config() {
+ let background =
+ AppConstants.platform === "android"
+ ? async () => {
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "none",
+ },
+ }),
+ /proxy.settings is not supported on android/,
+ "proxy.settings.set rejects on Android."
+ );
+
+ await browser.test.assertRejects(
+ browser.proxy.settings.get({}),
+ /proxy.settings is not supported on android/,
+ "proxy.settings.get rejects on Android."
+ );
+
+ await browser.test.assertRejects(
+ browser.proxy.settings.clear({}),
+ /proxy.settings is not supported on android/,
+ "proxy.settings.clear rejects on Android."
+ );
+
+ browser.test.sendMessage("done");
+ }
+ : async () => {
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "abc",
+ },
+ }),
+ /abc is not a valid value for proxyType/,
+ "proxy.settings.set rejects with an invalid proxyType value."
+ );
+
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "autoConfig",
+ },
+ }),
+ /undefined is not a valid value for autoConfigUrl/,
+ "proxy.settings.set for type autoConfig rejects with an empty autoConfigUrl value."
+ );
+
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "autoConfig",
+ autoConfigUrl: "abc",
+ },
+ }),
+ /abc is not a valid value for autoConfigUrl/,
+ "proxy.settings.set rejects with an invalid autoConfigUrl value."
+ );
+
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "manual",
+ socksVersion: "abc",
+ },
+ }),
+ /abc is not a valid value for socksVersion/,
+ "proxy.settings.set rejects with an invalid socksVersion value."
+ );
+
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "manual",
+ socksVersion: 3,
+ },
+ }),
+ /3 is not a valid value for socksVersion/,
+ "proxy.settings.set rejects with an invalid socksVersion value."
+ );
+
+ browser.test.sendMessage("done");
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["proxy"],
+ },
+ incognitoOverride: "spanning",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+// Verify proxy prefs are unset on permission removal.
+add_task(async function test_proxy_settings_permissions() {
+ async function background() {
+ const permObj = { permissions: ["proxy"] };
+ browser.test.onMessage.addListener(async (msg, value) => {
+ if (msg === "request") {
+ browser.test.log("requesting proxy permission");
+ await browser.permissions.request(permObj);
+ browser.test.log("setting proxy values");
+ await browser.proxy.settings.set({ value });
+ browser.test.sendMessage("set");
+ } else if (msg === "remove") {
+ await browser.permissions.remove(permObj);
+ browser.test.sendMessage("removed");
+ }
+ });
+ }
+
+ let prefNames = [
+ "network.proxy.type",
+ "network.proxy.http",
+ "network.proxy.http_port",
+ "network.proxy.ssl",
+ "network.proxy.ssl_port",
+ "network.proxy.socks",
+ "network.proxy.socks_port",
+ "network.proxy.socks_version",
+ "network.proxy.no_proxies_on",
+ ];
+
+ function checkSettings(msg, expectUserValue = false) {
+ info(msg);
+ for (let pref of prefNames) {
+ equal(
+ expectUserValue,
+ Services.prefs.prefHasUserValue(pref),
+ `${pref} set as expected ${Preferences.get(pref)}`
+ );
+ }
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ optional_permissions: ["proxy"],
+ },
+ incognitoOverride: "spanning",
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+ checkSettings("setting is not set after startup");
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request", {
+ proxyType: "manual",
+ http: "www.mozilla.org:8080",
+ httpProxyAll: false,
+ ssl: "www.mozilla.org:8082",
+ socks: "mozilla.org:8083",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ });
+ await extension.awaitMessage("set");
+ checkSettings("setting was set after request", true);
+
+ extension.sendMessage("remove");
+ await extension.awaitMessage("removed");
+ checkSettings("setting is reset after remove");
+
+ // Set again to test after restart
+ extension.sendMessage("request", {
+ proxyType: "manual",
+ http: "www.mozilla.org:8080",
+ httpProxyAll: false,
+ ssl: "www.mozilla.org:8082",
+ socks: "mozilla.org:8083",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ });
+ await extension.awaitMessage("set");
+ checkSettings("setting was set after request", true);
+ });
+
+ // force the permissions store to be re-read on startup
+ await ExtensionPermissions._uninit();
+ resetHandlingUserInput();
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitBackgroundStarted();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("remove");
+ await extension.awaitMessage("removed");
+ checkSettings("setting is reset after remove");
+ });
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_containerIsolation.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_containerIsolation.js
new file mode 100644
index 0000000000..9a375f68a9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_containerIsolation.js
@@ -0,0 +1,59 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_userContextId_proxy() {
+ Services.prefs.setBoolPref("extensions.userContextIsolation.enabled", true);
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background() {
+ browser.proxy.onRequest.addListener(
+ async details => {
+ browser.test.assertEq(
+ "firefox-container-2",
+ details.cookieStoreId,
+ "cookieStoreId is set"
+ );
+ browser.test.notifyPass("allowed");
+ },
+ { urls: ["http://example.com/dummy"] }
+ );
+ },
+ });
+
+ Services.prefs.setCharPref(
+ "extensions.userContextIsolation.defaults.restricted",
+ "[1]"
+ );
+ await extension.startup();
+
+ let restrictedPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy",
+ { userContextId: 1 }
+ );
+
+ let allowedPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy",
+ {
+ userContextId: 2,
+ }
+ );
+ await extension.awaitFinish("allowed");
+
+ await extension.unload();
+ await restrictedPage.close();
+ await allowedPage.close();
+
+ Services.prefs.clearUserPref("extensions.userContextIsolation.enabled");
+ Services.prefs.clearUserPref(
+ "extensions.userContextIsolation.defaults.restricted"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js
new file mode 100644
index 0000000000..db041d20d0
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js
@@ -0,0 +1,302 @@
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "authManager",
+ "@mozilla.org/network/http-auth-manager;1",
+ "nsIHttpAuthManager"
+);
+
+const proxy = createHttpServer();
+
+// accept proxy connections for mozilla.org
+proxy.identity.add("http", "mozilla.org", 80);
+proxy.identity.add("https", "407.example.com", 443);
+
+proxy.registerPathHandler("CONNECT", (request, response) => {
+ Assert.equal(request.method, "CONNECT");
+ switch (request.host) {
+ case "407.example.com":
+ response.setStatusLine(request.httpVersion, 407, "Authenticate");
+ response.setHeader("Content-Type", "text/plain", false);
+ response.setHeader("Proxy-Authenticate", 'Basic realm="foobar"', false);
+ response.write("auth required");
+ break;
+ default:
+ response.setStatusLine(request.httpVersion, 500, "I am dumb");
+ }
+});
+
+proxy.registerPathHandler("/", (request, response) => {
+ if (request.hasHeader("Proxy-Authorization")) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/plain", false);
+ response.write("ok, got proxy auth");
+ } else {
+ response.setStatusLine(
+ request.httpVersion,
+ 407,
+ "Proxy authentication required"
+ );
+ response.setHeader("Content-Type", "text/plain", false);
+ response.setHeader("Proxy-Authenticate", 'Basic realm="foobar"', false);
+ response.write("auth required");
+ }
+});
+
+function getExtension(background) {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background: `(${background})(${proxy.identity.primaryPort})`,
+ });
+}
+
+add_task(async function test_webRequest_auth_proxy() {
+ async function background(port) {
+ let expecting = [
+ "onBeforeSendHeaders",
+ "onSendHeaders",
+ "onAuthRequired",
+ "onBeforeSendHeaders",
+ "onSendHeaders",
+ "onCompleted",
+ ];
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ browser.test.log(`onBeforeSendHeaders ${JSON.stringify(details)}\n`);
+ browser.test.assertEq(
+ "onBeforeSendHeaders",
+ expecting.shift(),
+ "got expected event"
+ );
+ browser.test.assertEq(
+ "localhost",
+ details.proxyInfo.host,
+ "proxy host"
+ );
+ browser.test.assertEq(port, details.proxyInfo.port, "proxy port");
+ browser.test.assertEq("http", details.proxyInfo.type, "proxy type");
+ browser.test.assertEq(
+ "",
+ details.proxyInfo.username,
+ "proxy username not set"
+ );
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.webRequest.onSendHeaders.addListener(
+ details => {
+ browser.test.log(`onSendHeaders ${JSON.stringify(details)}\n`);
+ browser.test.assertEq(
+ "onSendHeaders",
+ expecting.shift(),
+ "got expected event"
+ );
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ browser.test.log(`onAuthRequired ${JSON.stringify(details)}\n`);
+ browser.test.assertEq(
+ "onAuthRequired",
+ expecting.shift(),
+ "got expected event"
+ );
+ browser.test.assertTrue(details.isProxy, "proxied request");
+ browser.test.assertEq(
+ "localhost",
+ details.proxyInfo.host,
+ "proxy host"
+ );
+ browser.test.assertEq(port, details.proxyInfo.port, "proxy port");
+ browser.test.assertEq("http", details.proxyInfo.type, "proxy type");
+ browser.test.assertEq(
+ "localhost",
+ details.challenger.host,
+ "proxy host"
+ );
+ browser.test.assertEq(port, details.challenger.port, "proxy port");
+ return { authCredentials: { username: "puser", password: "ppass" } };
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.log(`onCompleted ${JSON.stringify(details)}\n`);
+ browser.test.assertEq(
+ "onCompleted",
+ expecting.shift(),
+ "got expected event"
+ );
+ browser.test.assertEq(
+ "localhost",
+ details.proxyInfo.host,
+ "proxy host"
+ );
+ browser.test.assertEq(port, details.proxyInfo.port, "proxy port");
+ browser.test.assertEq("http", details.proxyInfo.type, "proxy type");
+ browser.test.assertEq(
+ "",
+ details.proxyInfo.username,
+ "proxy username not set by onAuthRequired"
+ );
+ browser.test.assertEq(
+ undefined,
+ details.proxyInfo.password,
+ "no proxy password"
+ );
+ browser.test.assertEq(expecting.length, 0, "got all expected events");
+ browser.test.sendMessage("done");
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ // Handle the proxy request.
+ browser.proxy.onRequest.addListener(
+ details => {
+ browser.test.log(`onRequest ${JSON.stringify(details)}`);
+ return [{ host: "localhost", port, type: "http" }];
+ },
+ { urls: ["<all_urls>"] },
+ ["requestHeaders"]
+ );
+ browser.test.sendMessage("ready");
+ }
+
+ let handlingExt = getExtension(background);
+
+ await handlingExt.startup();
+ await handlingExt.awaitMessage("ready");
+
+ authManager.clearAll();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `http://mozilla.org/`
+ );
+
+ await handlingExt.awaitMessage("done");
+ await contentPage.close();
+ await handlingExt.unload();
+});
+
+add_task(async function test_webRequest_auth_proxy_https() {
+ async function background(port) {
+ let authReceived = false;
+
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ if (authReceived) {
+ browser.test.sendMessage("done");
+ return { cancel: true };
+ }
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ authReceived = true;
+ return { authCredentials: { username: "puser", password: "ppass" } };
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+
+ // Handle the proxy request.
+ browser.proxy.onRequest.addListener(
+ details => {
+ browser.test.log(`onRequest ${JSON.stringify(details)}`);
+ return [{ host: "localhost", port, type: "http" }];
+ },
+ { urls: ["<all_urls>"] },
+ ["requestHeaders"]
+ );
+ browser.test.sendMessage("ready");
+ }
+
+ let handlingExt = getExtension(background);
+
+ await handlingExt.startup();
+ await handlingExt.awaitMessage("ready");
+
+ authManager.clearAll();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `https://407.example.com/`
+ );
+
+ await handlingExt.awaitMessage("done");
+ await contentPage.close();
+ await handlingExt.unload();
+});
+
+add_task(async function test_webRequest_auth_proxy_system() {
+ async function background(port) {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.fail("onBeforeRequest");
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ browser.test.sendMessage("onAuthRequired");
+ // cancel is silently ignored, if it were not (e.g someone messes up in
+ // WebRequest.jsm and allows cancel) this test would fail.
+ return {
+ cancel: true,
+ authCredentials: { username: "puser", password: "ppass" },
+ };
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+
+ // Handle the proxy request.
+ browser.proxy.onRequest.addListener(
+ details => {
+ browser.test.log(`onRequest ${JSON.stringify(details)}`);
+ return { host: "localhost", port, type: "http" };
+ },
+ { urls: ["<all_urls>"] }
+ );
+ browser.test.sendMessage("ready");
+ }
+
+ let handlingExt = getExtension(background);
+
+ await handlingExt.startup();
+ await handlingExt.awaitMessage("ready");
+
+ authManager.clearAll();
+
+ function fetch(url) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.mozBackgroundRequest = true;
+ xhr.open("GET", url);
+ xhr.onload = () => {
+ resolve(xhr.responseText);
+ };
+ xhr.onerror = () => {
+ reject(xhr.status);
+ };
+ xhr.send();
+ });
+ }
+
+ await Promise.all([
+ handlingExt.awaitMessage("onAuthRequired"),
+ fetch("http://mozilla.org"),
+ ]);
+ await handlingExt.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js
new file mode 100644
index 0000000000..8c558d6aa5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js
@@ -0,0 +1,104 @@
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "HttpServer",
+ "resource://testing-common/httpd.js"
+);
+
+const { createAppInfo, promiseShutdownManager, promiseStartupManager } =
+ AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+// We cannot use createHttpServer because it also messes with proxies. We want
+// httpChannel to pick up the prefs we set and use those to proxy to our server.
+// If this were to fail, we would get an error about making a request out to
+// the network.
+const proxy = new HttpServer();
+proxy.start(-1);
+proxy.registerPathHandler("/fubar", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+registerCleanupFunction(() => {
+ return new Promise(resolve => {
+ proxy.stop(resolve);
+ });
+});
+
+add_task(async function test_proxy_settings() {
+ async function background(host, port) {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(
+ host,
+ details.proxyInfo.host,
+ "proxy host matched"
+ );
+ browser.test.assertEq(
+ port,
+ details.proxyInfo.port,
+ "proxy port matched"
+ );
+ },
+ { urls: ["http://example.com/*"] }
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.notifyPass("proxytest");
+ },
+ { urls: ["http://example.com/*"] }
+ );
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.notifyFail("proxytest");
+ },
+ { urls: ["http://example.com/*"] }
+ );
+
+ // Wait for the settings before testing a request.
+ await browser.proxy.settings.set({
+ value: {
+ proxyType: "manual",
+ http: `${host}:${port}`,
+ },
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "proxy.settings@mochi.test" } },
+ permissions: ["proxy", "webRequest", "<all_urls>"],
+ },
+ incognitoOverride: "spanning",
+ useAddonManager: "temporary",
+ background: `(${background})("${proxy.identity.primaryHost}", ${proxy.identity.primaryPort})`,
+ });
+
+ await promiseStartupManager();
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ equal(
+ Services.prefs.getStringPref("network.proxy.http"),
+ proxy.identity.primaryHost,
+ "proxy address is set"
+ );
+ equal(
+ Services.prefs.getIntPref("network.proxy.http_port"),
+ proxy.identity.primaryPort,
+ "proxy port is set"
+ );
+ let ok = extension.awaitFinish("proxytest");
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/fubar"
+ );
+ await ok;
+
+ await contentPage.close();
+ await extension.unload();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js
new file mode 100644
index 0000000000..6ebd9fbfcc
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js
@@ -0,0 +1,660 @@
+"use strict";
+
+/* globals TCPServerSocket */
+
+const CC = Components.Constructor;
+
+const BinaryInputStream = CC(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+
+const currentThread =
+ Cc["@mozilla.org/thread-manager;1"].getService().currentThread;
+
+// Most of the socks logic here is copied and upgraded to support authentication
+// for socks5. The original test is from netwerk/test/unit/test_socks.js
+
+// Socks 4 support was left in place for future tests.
+
+const STATE_WAIT_GREETING = 1;
+const STATE_WAIT_SOCKS4_REQUEST = 2;
+const STATE_WAIT_SOCKS4_USERNAME = 3;
+const STATE_WAIT_SOCKS4_HOSTNAME = 4;
+const STATE_WAIT_SOCKS5_GREETING = 5;
+const STATE_WAIT_SOCKS5_REQUEST = 6;
+const STATE_WAIT_SOCKS5_AUTH = 7;
+const STATE_WAIT_INPUT = 8;
+const STATE_FINISHED = 9;
+
+/**
+ * A basic socks proxy setup that handles a single http response page. This
+ * is used for testing socks auth with webrequest. We don't bother making
+ * sure we buffer ondata, etc., we'll never get anything but tiny chunks here.
+ */
+class SocksClient {
+ constructor(server, socket) {
+ this.server = server;
+ this.type = "";
+ this.username = "";
+ this.dest_name = "";
+ this.dest_addr = [];
+ this.dest_port = [];
+
+ this.inbuf = [];
+ this.state = STATE_WAIT_GREETING;
+ this.socket = socket;
+
+ socket.onclose = event => {
+ this.server.requestCompleted(this);
+ };
+ socket.ondata = event => {
+ let len = event.data.byteLength;
+
+ if (len == 0 && this.state == STATE_FINISHED) {
+ this.close();
+ this.server.requestCompleted(this);
+ return;
+ }
+
+ this.inbuf = new Uint8Array(event.data);
+ Promise.resolve().then(() => {
+ this.callState();
+ });
+ };
+ }
+
+ callState() {
+ switch (this.state) {
+ case STATE_WAIT_GREETING:
+ this.checkSocksGreeting();
+ break;
+ case STATE_WAIT_SOCKS4_REQUEST:
+ this.checkSocks4Request();
+ break;
+ case STATE_WAIT_SOCKS4_USERNAME:
+ this.checkSocks4Username();
+ break;
+ case STATE_WAIT_SOCKS4_HOSTNAME:
+ this.checkSocks4Hostname();
+ break;
+ case STATE_WAIT_SOCKS5_GREETING:
+ this.checkSocks5Greeting();
+ break;
+ case STATE_WAIT_SOCKS5_REQUEST:
+ this.checkSocks5Request();
+ break;
+ case STATE_WAIT_SOCKS5_AUTH:
+ this.checkSocks5Auth();
+ break;
+ case STATE_WAIT_INPUT:
+ this.checkRequest();
+ break;
+ default:
+ do_throw("server: read in invalid state!");
+ }
+ }
+
+ write(buf) {
+ this.socket.send(new Uint8Array(buf).buffer);
+ }
+
+ checkSocksGreeting() {
+ if (!this.inbuf.length) {
+ return;
+ }
+
+ if (this.inbuf[0] == 4) {
+ this.type = "socks4";
+ this.state = STATE_WAIT_SOCKS4_REQUEST;
+ this.checkSocks4Request();
+ } else if (this.inbuf[0] == 5) {
+ this.type = "socks";
+ this.state = STATE_WAIT_SOCKS5_GREETING;
+ this.checkSocks5Greeting();
+ } else {
+ do_throw("Unknown socks protocol!");
+ }
+ }
+
+ checkSocks4Request() {
+ if (this.inbuf.length < 8) {
+ return;
+ }
+
+ this.dest_port = this.inbuf.slice(2, 4);
+ this.dest_addr = this.inbuf.slice(4, 8);
+
+ this.inbuf = this.inbuf.slice(8);
+ this.state = STATE_WAIT_SOCKS4_USERNAME;
+ this.checkSocks4Username();
+ }
+
+ readString() {
+ let i = this.inbuf.indexOf(0);
+ let str = null;
+
+ if (i >= 0) {
+ let decoder = new TextDecoder();
+ str = decoder.decode(this.inbuf.slice(0, i));
+ this.inbuf = this.inbuf.slice(i + 1);
+ }
+
+ return str;
+ }
+
+ checkSocks4Username() {
+ let str = this.readString();
+
+ if (str == null) {
+ return;
+ }
+
+ this.username = str;
+ if (
+ this.dest_addr[0] == 0 &&
+ this.dest_addr[1] == 0 &&
+ this.dest_addr[2] == 0 &&
+ this.dest_addr[3] != 0
+ ) {
+ this.state = STATE_WAIT_SOCKS4_HOSTNAME;
+ this.checkSocks4Hostname();
+ } else {
+ this.sendSocks4Response();
+ }
+ }
+
+ checkSocks4Hostname() {
+ let str = this.readString();
+
+ if (str == null) {
+ return;
+ }
+
+ this.dest_name = str;
+ this.sendSocks4Response();
+ }
+
+ sendSocks4Response() {
+ this.state = STATE_WAIT_INPUT;
+ this.inbuf = [];
+ this.write([0, 0x5a, 0, 0, 0, 0, 0, 0]);
+ }
+
+ /**
+ * checks authentication information.
+ *
+ * buf[0] socks version
+ * buf[1] number of auth methods supported
+ * buf[2+nmethods] value for each auth method
+ *
+ * Response is
+ * byte[0] socks version
+ * byte[1] desired auth method
+ *
+ * For whatever reason, Firefox does not present auth method 0x02 however
+ * responding with that does cause Firefox to send authentication if
+ * the nsIProxyInfo instance has the data. IUUC Firefox should send
+ * supported methods, but I'm no socks expert.
+ */
+ checkSocks5Greeting() {
+ if (this.inbuf.length < 2) {
+ return;
+ }
+ let nmethods = this.inbuf[1];
+ if (this.inbuf.length < 2 + nmethods) {
+ return;
+ }
+
+ // See comment above, keeping for future update.
+ // let methods = this.inbuf.slice(2, 2 + nmethods);
+
+ this.inbuf = [];
+ if (this.server.password || this.server.username) {
+ this.state = STATE_WAIT_SOCKS5_AUTH;
+ this.write([5, 2]);
+ } else {
+ this.state = STATE_WAIT_SOCKS5_REQUEST;
+ this.write([5, 0]);
+ }
+ }
+
+ checkSocks5Auth() {
+ equal(this.inbuf[0], 0x01, "subnegotiation version");
+ let uname_len = this.inbuf[1];
+ let pass_len = this.inbuf[2 + uname_len];
+ let unnamebuf = this.inbuf.slice(2, 2 + uname_len);
+ let pass_start = 2 + uname_len + 1;
+ let pwordbuf = this.inbuf.slice(pass_start, pass_start + pass_len);
+ let decoder = new TextDecoder();
+ let username = decoder.decode(unnamebuf);
+ let password = decoder.decode(pwordbuf);
+ this.inbuf = [];
+ equal(username, this.server.username, "socks auth username");
+ equal(password, this.server.password, "socks auth password");
+ if (username == this.server.username && password == this.server.password) {
+ this.state = STATE_WAIT_SOCKS5_REQUEST;
+ // x00 is success, any other value closes the connection
+ this.write([1, 0]);
+ return;
+ }
+ this.state = STATE_FINISHED;
+ this.write([1, 1]);
+ }
+
+ checkSocks5Request() {
+ if (this.inbuf.length < 4) {
+ return;
+ }
+
+ let atype = this.inbuf[3];
+ let len;
+ let name = false;
+
+ switch (atype) {
+ case 0x01:
+ len = 4;
+ break;
+ case 0x03:
+ len = this.inbuf[4];
+ name = true;
+ break;
+ case 0x04:
+ len = 16;
+ break;
+ default:
+ do_throw("Unknown address type " + atype);
+ }
+
+ if (name) {
+ if (this.inbuf.length < 4 + len + 1 + 2) {
+ return;
+ }
+
+ let buf = this.inbuf.slice(5, 5 + len);
+ let decoder = new TextDecoder();
+ this.dest_name = decoder.decode(buf);
+ len += 1;
+ } else {
+ if (this.inbuf.length < 4 + len + 2) {
+ return;
+ }
+
+ this.dest_addr = this.inbuf.slice(4, 4 + len);
+ }
+
+ len += 4;
+ this.dest_port = this.inbuf.slice(len, len + 2);
+ this.inbuf = this.inbuf.slice(len + 2);
+ this.sendSocks5Response();
+ }
+
+ sendSocks5Response() {
+ let buf;
+ if (this.dest_addr.length == 16) {
+ // send a successful response with the address, [::1]:80
+ buf = [5, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 80];
+ } else {
+ // send a successful response with the address, 127.0.0.1:80
+ buf = [5, 0, 0, 1, 127, 0, 0, 1, 0, 80];
+ }
+ this.state = STATE_WAIT_INPUT;
+ this.inbuf = [];
+ this.write(buf);
+ }
+
+ checkRequest() {
+ let decoder = new TextDecoder();
+ let request = decoder.decode(this.inbuf);
+
+ if (request == "PING!") {
+ this.state = STATE_FINISHED;
+ this.socket.send("PONG!");
+ } else if (request.startsWith("GET / HTTP/1.1")) {
+ this.socket.send(
+ "HTTP/1.1 200 OK\r\n" +
+ "Content-Length: 2\r\n" +
+ "Content-Type: text/html\r\n" +
+ "\r\nOK"
+ );
+ this.state = STATE_FINISHED;
+ }
+ }
+
+ close() {
+ this.socket.close();
+ }
+}
+
+class SocksTestServer {
+ constructor() {
+ this.client_connections = new Set();
+ this.listener = new TCPServerSocket(-1, { binaryType: "arraybuffer" }, -1);
+ this.listener.onconnect = event => {
+ let client = new SocksClient(this, event.socket);
+ this.client_connections.add(client);
+ };
+ }
+
+ requestCompleted(client) {
+ this.client_connections.delete(client);
+ }
+
+ close() {
+ for (let client of this.client_connections) {
+ client.close();
+ }
+ this.client_connections = new Set();
+ if (this.listener) {
+ this.listener.close();
+ this.listener = null;
+ }
+ }
+
+ setUserPass(username, password) {
+ this.username = username;
+ this.password = password;
+ }
+}
+
+/**
+ * Tests the basic socks logic using a simple socket connection and the
+ * protocol proxy service. Before 902346, TCPSocket has no way to tie proxy
+ * data to it, so we go old school here.
+ */
+class SocksTestClient {
+ constructor(socks, dest, resolve, reject) {
+ let pps = Cc["@mozilla.org/network/protocol-proxy-service;1"].getService(
+ Ci.nsIProtocolProxyService
+ );
+ let sts = Cc["@mozilla.org/network/socket-transport-service;1"].getService(
+ Ci.nsISocketTransportService
+ );
+
+ let pi_flags = 0;
+ if (socks.dns == "remote") {
+ pi_flags = Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST;
+ }
+
+ let pi = pps.newProxyInfoWithAuth(
+ socks.version,
+ socks.host,
+ socks.port,
+ socks.username,
+ socks.password,
+ "",
+ "",
+ pi_flags,
+ -1,
+ null
+ );
+
+ this.trans = sts.createTransport([], dest.host, dest.port, pi, null);
+ this.input = this.trans.openInputStream(
+ Ci.nsITransport.OPEN_BLOCKING,
+ 0,
+ 0
+ );
+ this.output = this.trans.openOutputStream(
+ Ci.nsITransport.OPEN_BLOCKING,
+ 0,
+ 0
+ );
+ this.outbuf = String();
+ this.resolve = resolve;
+ this.reject = reject;
+
+ this.write("PING!");
+ this.input.asyncWait(this, 0, 0, currentThread);
+ }
+
+ onInputStreamReady(stream) {
+ let len = 0;
+ try {
+ len = stream.available();
+ } catch (e) {
+ // This will happen on auth failure.
+ this.reject(e);
+ return;
+ }
+ let bin = new BinaryInputStream(stream);
+ let data = bin.readByteArray(len);
+ let decoder = new TextDecoder();
+ let result = decoder.decode(data);
+ if (result == "PONG!") {
+ this.resolve(result);
+ } else {
+ this.reject();
+ }
+ }
+
+ write(buf) {
+ this.outbuf += buf;
+ this.output.asyncWait(this, 0, 0, currentThread);
+ }
+
+ onOutputStreamReady(stream) {
+ let len = stream.write(this.outbuf, this.outbuf.length);
+ if (len != this.outbuf.length) {
+ this.outbuf = this.outbuf.substring(len);
+ stream.asyncWait(this, 0, 0, currentThread);
+ } else {
+ this.outbuf = String();
+ }
+ }
+
+ close() {
+ this.output.close();
+ }
+}
+
+const socksServer = new SocksTestServer();
+socksServer.setUserPass("foo", "bar");
+registerCleanupFunction(() => {
+ socksServer.close();
+});
+
+// A simple ping/pong to test the socks server.
+add_task(async function test_socks_server() {
+ let socks = {
+ version: "socks",
+ host: "127.0.0.1",
+ port: socksServer.listener.localPort,
+ username: "foo",
+ password: "bar",
+ dns: false,
+ };
+ let dest = {
+ host: "localhost",
+ port: 8888,
+ };
+
+ new Promise((resolve, reject) => {
+ new SocksTestClient(socks, dest, resolve, reject);
+ })
+ .then(result => {
+ equal("PONG!", result, "socks test ok");
+ })
+ .catch(result => {
+ ok(false, `socks test failed ${result}`);
+ });
+});
+
+// Register a proxy to be used by TCPSocket connections later.
+function registerProxy(socks) {
+ let pps = Cc["@mozilla.org/network/protocol-proxy-service;1"].getService(
+ Ci.nsIProtocolProxyService
+ );
+ let filter = {
+ QueryInterface: ChromeUtils.generateQI(["nsIProtocolProxyFilter"]),
+ applyFilter(uri, proxyInfo, callback) {
+ callback.onProxyFilterResult(
+ pps.newProxyInfoWithAuth(
+ socks.version,
+ socks.host,
+ socks.port,
+ socks.username,
+ socks.password,
+ "",
+ "",
+ socks.dns == "remote"
+ ? Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST
+ : 0,
+ -1,
+ null
+ )
+ );
+ },
+ };
+ pps.registerFilter(filter, 0);
+ registerCleanupFunction(() => {
+ pps.unregisterFilter(filter);
+ });
+}
+
+// A simple ping/pong to test the socks server with TCPSocket.
+add_task(async function test_tcpsocket_proxy() {
+ let socks = {
+ version: "socks",
+ host: "127.0.0.1",
+ port: socksServer.listener.localPort,
+ username: "foo",
+ password: "bar",
+ dns: false,
+ };
+ let dest = {
+ host: "localhost",
+ port: 8888,
+ };
+
+ registerProxy(socks);
+ await new Promise((resolve, reject) => {
+ let client = new TCPSocket(dest.host, dest.port);
+ client.onopen = () => {
+ client.send("PING!");
+ };
+ client.ondata = e => {
+ equal("PONG!", e.data, "socks test ok");
+ resolve();
+ };
+ client.onerror = () => reject();
+ });
+});
+
+add_task(async function test_webRequest_socks_proxy() {
+ async function background(port) {
+ function checkProxyData(details) {
+ browser.test.assertEq("127.0.0.1", details.proxyInfo.host, "proxy host");
+ browser.test.assertEq(port, details.proxyInfo.port, "proxy port");
+ browser.test.assertEq("socks", details.proxyInfo.type, "proxy type");
+ browser.test.assertEq(
+ "foo",
+ details.proxyInfo.username,
+ "proxy username not set"
+ );
+ browser.test.assertEq(
+ undefined,
+ details.proxyInfo.password,
+ "no proxy password passed to webrequest"
+ );
+ }
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ checkProxyData(details);
+ },
+ { urls: ["<all_urls>"] }
+ );
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ // We should never get onAuthRequired for socks proxy
+ browser.test.fail("onAuthRequired");
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ checkProxyData(details);
+ browser.test.sendMessage("done");
+ },
+ { urls: ["<all_urls>"] }
+ );
+ browser.proxy.onRequest.addListener(
+ () => {
+ return [
+ {
+ type: "socks",
+ host: "127.0.0.1",
+ port,
+ username: "foo",
+ password: "bar",
+ },
+ ];
+ },
+ { urls: ["<all_urls>"] }
+ );
+ }
+
+ let handlingExt = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background: `(${background})(${socksServer.listener.localPort})`,
+ });
+
+ // proxy.register is deprecated - bug 1443259.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await handlingExt.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `http://localhost/`
+ );
+
+ await handlingExt.awaitMessage("done");
+ await contentPage.close();
+ await handlingExt.unload();
+});
+
+add_task(async function test_onRequest_tcpsocket_proxy() {
+ async function background(port) {
+ browser.proxy.onRequest.addListener(
+ () => {
+ return [
+ {
+ type: "socks",
+ host: "127.0.0.1",
+ port,
+ username: "foo",
+ password: "bar",
+ },
+ ];
+ },
+ { urls: ["<all_urls>"] }
+ );
+ }
+
+ let handlingExt = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background: `(${background})(${socksServer.listener.localPort})`,
+ });
+
+ await handlingExt.startup();
+
+ await new Promise((resolve, reject) => {
+ let client = new TCPSocket("localhost", 8888);
+ client.onopen = () => {
+ client.send("PING!");
+ };
+ client.ondata = e => {
+ equal("PONG!", e.data, "socks test ok");
+ resolve();
+ };
+ client.onerror = () => reject();
+ });
+
+ await handlingExt.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js
new file mode 100644
index 0000000000..25b7030671
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js
@@ -0,0 +1,53 @@
+"use strict";
+
+const { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+
+const proxy = createHttpServer();
+
+add_task(async function test_speculative_connect() {
+ function background() {
+ // Handle the proxy request.
+ browser.proxy.onRequest.addListener(
+ details => {
+ browser.test.log(`onRequest ${JSON.stringify(details)}`);
+ browser.test.assertEq(
+ details.type,
+ "speculative",
+ "Should have seen a speculative proxy request."
+ );
+ return [{ type: "direct" }];
+ },
+ { urls: ["<all_urls>"], types: ["speculative"] }
+ );
+ }
+
+ let handlingExt = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background: `(${background})()`,
+ });
+
+ Services.prefs.setBoolPref("network.http.debug-observations", true);
+
+ await handlingExt.startup();
+
+ let notificationPromise = ExtensionUtils.promiseObserved(
+ "speculative-connect-request"
+ );
+
+ let uri = Services.io.newURI(
+ `http://${proxy.identity.primaryHost}:${proxy.identity.primaryPort}`
+ );
+ Services.io.speculativeConnect(
+ uri,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ false
+ );
+ await notificationPromise;
+
+ await handlingExt.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js
new file mode 100644
index 0000000000..d6a016a594
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js
@@ -0,0 +1,144 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+let { promiseRestartManager, promiseShutdownManager, promiseStartupManager } =
+ AddonTestUtils;
+
+let nonProxiedRequests = 0;
+const nonProxiedServer = createHttpServer({ hosts: ["example.com"] });
+nonProxiedServer.registerPathHandler("/", (request, response) => {
+ nonProxiedRequests++;
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+// No hosts defined to avoid proxy filter setup.
+let proxiedRequests = 0;
+const server = createHttpServer();
+server.identity.add("http", "proxied.example.com", 80);
+server.registerPathHandler("/", (request, response) => {
+ proxiedRequests++;
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+function trackEvents(wrapper) {
+ let events = new Map();
+ for (let event of ["background-script-event", "start-background-script"]) {
+ events.set(event, false);
+ wrapper.extension.once(event, () => events.set(event, true));
+ }
+ return events;
+}
+
+// Test that a proxy listener during startup does not immediately
+// start the background page, but the event is queued until the background
+// page is started.
+add_task(async function test_proxy_startup() {
+ await promiseStartupManager();
+
+ function background(proxyInfo) {
+ browser.proxy.onRequest.addListener(
+ details => {
+ // ignore speculative requests
+ if (details.type == "xmlhttprequest") {
+ browser.test.sendMessage("saw-request");
+ }
+ return proxyInfo;
+ },
+ { urls: ["<all_urls>"] }
+ );
+ }
+
+ let proxyInfo = {
+ host: server.identity.primaryHost,
+ port: server.identity.primaryPort,
+ type: "http",
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["proxy", "http://proxied.example.com/*"],
+ },
+ background: `(${background})(${JSON.stringify(proxyInfo)})`,
+ });
+
+ await extension.startup();
+
+ // Initial requests to test the proxy and non-proxied servers.
+ await Promise.all([
+ extension.awaitMessage("saw-request"),
+ ExtensionTestUtils.fetch("http://proxied.example.com/?a=0"),
+ ]);
+ equal(1, proxiedRequests, "proxied request ok");
+ equal(0, nonProxiedRequests, "non proxied request ok");
+
+ await ExtensionTestUtils.fetch("http://example.com/?a=0");
+ equal(1, proxiedRequests, "proxied request ok");
+ equal(1, nonProxiedRequests, "non proxied request ok");
+
+ await promiseRestartManager({ earlyStartup: false });
+ await extension.awaitStartup();
+
+ let events = trackEvents(extension);
+
+ // Initiate a non-proxied request to make sure the startup listeners are using
+ // the extensions filters/etc.
+ await ExtensionTestUtils.fetch("http://example.com/?a=1");
+ equal(1, proxiedRequests, "proxied request ok");
+ equal(2, nonProxiedRequests, "non proxied request ok");
+
+ equal(
+ events.get("background-script-event"),
+ false,
+ "Should not have gotten a background script event"
+ );
+
+ // Make a request that the extension will proxy once it is started.
+ let request = Promise.all([
+ extension.awaitMessage("saw-request"),
+ ExtensionTestUtils.fetch("http://proxied.example.com/?a=1"),
+ ]);
+
+ await promiseExtensionEvent(extension, "background-script-event");
+ equal(
+ events.get("background-script-event"),
+ true,
+ "Should have gotten a background script event"
+ );
+
+ // Test the background page startup.
+ equal(
+ events.get("start-background-script"),
+ false,
+ "Should have gotten a background script event"
+ );
+
+ AddonTestUtils.notifyEarlyStartup();
+ await new Promise(executeSoon);
+
+ equal(
+ events.get("start-background-script"),
+ true,
+ "Should have gotten a background script event"
+ );
+
+ // Verify our proxied request finishes properly and that the
+ // request was not handled via our non-proxied server.
+ await request;
+ equal(2, proxiedRequests, "proxied request ok");
+ equal(2, nonProxiedRequests, "non proxied requests ok");
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js b/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js
new file mode 100644
index 0000000000..7b950355f3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js
@@ -0,0 +1,660 @@
+"use strict";
+
+// Tests whether we can redirect to a moz-extension: url.
+ChromeUtils.defineESModuleGetters(this, {
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+const server = createHttpServer();
+const gServerUrl = `http://localhost:${server.identity.primaryPort}`;
+
+server.registerPathHandler("/redirect", (request, response) => {
+ let params = new URLSearchParams(request.queryString);
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ response.setHeader("Location", params.get("redirect_uri"));
+ response.write("redirecting");
+});
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+server.registerPathHandler("/dummy-2", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+function onStopListener(channel) {
+ return new Promise(resolve => {
+ let orig = channel.QueryInterface(Ci.nsITraceableChannel).setNewListener({
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIRequestObserver",
+ "nsIStreamListener",
+ ]),
+ getFinalURI(request) {
+ let { loadInfo } = request;
+ return (loadInfo && loadInfo.resultPrincipalURI) || request.originalURI;
+ },
+ onDataAvailable(...args) {
+ orig.onDataAvailable(...args);
+ },
+ onStartRequest(request) {
+ orig.onStartRequest(request);
+ },
+ onStopRequest(request, statusCode) {
+ orig.onStopRequest(request, statusCode);
+ let URI = this.getFinalURI(request.QueryInterface(Ci.nsIChannel));
+ resolve(URI && URI.spec);
+ },
+ });
+ });
+}
+
+async function onModifyListener(originUrl, redirectToUrl) {
+ return TestUtils.topicObserved("http-on-modify-request", (subject, data) => {
+ let channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ return channel.URI && channel.URI.spec == originUrl;
+ }).then(([subject, data]) => {
+ let channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ if (redirectToUrl) {
+ channel.redirectTo(Services.io.newURI(redirectToUrl));
+ }
+ return channel;
+ });
+}
+
+function getExtension(
+ accessible = false,
+ background = undefined,
+ blocking = true
+) {
+ let manifest = {
+ permissions: ["webRequest", "<all_urls>"],
+ };
+ if (blocking) {
+ manifest.permissions.push("webRequestBlocking");
+ }
+ if (accessible) {
+ manifest.web_accessible_resources = ["finished.html"];
+ }
+ if (!background) {
+ background = () => {
+ // send the extensions public uri to the test.
+ let exturi = browser.runtime.getURL("finished.html");
+ browser.test.sendMessage("redirectURI", exturi);
+ };
+ }
+ return ExtensionTestUtils.loadExtension({
+ manifest,
+ files: {
+ "finished.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <h1>redirected!</h1>
+ </body>
+ </html>
+ `.trim(),
+ },
+ background,
+ });
+}
+
+async function redirection_test(url, channelRedirectUrl) {
+ // setup our observer
+ let watcher = onModifyListener(url, channelRedirectUrl).then(channel => {
+ return onStopListener(channel);
+ });
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", url);
+ xhr.send();
+ return watcher;
+}
+
+// This test verifies failure without web_accessible_resources.
+add_task(async function test_redirect_to_non_accessible_resource() {
+ let extension = getExtension();
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`;
+ let result = await redirection_test(url);
+ equal(result, url, `expected no redirect`);
+ await extension.unload();
+});
+
+// This test makes a request against a server that redirects with a 302.
+add_task(async function test_302_redirect_to_extension() {
+ let extension = getExtension(true);
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`;
+ let result = await redirection_test(url);
+ equal(result, redirectUrl, "redirect request is finished");
+ await extension.unload();
+});
+
+// This test uses channel.redirectTo during http-on-modify to redirect to the
+// moz-extension url.
+add_task(async function test_channel_redirect_to_extension() {
+ let extension = getExtension(true);
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let url = `${gServerUrl}/dummy?r=${Math.random()}`;
+ let result = await redirection_test(url, redirectUrl);
+ equal(result, redirectUrl, "redirect request is finished");
+ await extension.unload();
+});
+
+// This test verifies failure without web_accessible_resources.
+add_task(async function test_content_redirect_to_non_accessible_resource() {
+ let extension = getExtension();
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`;
+ let watcher = onModifyListener(url).then(channel => {
+ return onStopListener(channel);
+ });
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl: "about:blank",
+ });
+ equal(
+ contentPage.browser.documentURI.spec,
+ "about:blank",
+ `expected no redirect`
+ );
+ equal(await watcher, url, "expected no redirect");
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test makes a request against a server that redirects with a 302.
+add_task(async function test_content_302_redirect_to_extension() {
+ let extension = getExtension(true);
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`);
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test uses channel.redirectTo during http-on-modify to redirect to the
+// moz-extension url.
+add_task(async function test_content_channel_redirect_to_extension() {
+ let extension = getExtension(true);
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let url = `${gServerUrl}/dummy?r=${Math.random()}`;
+ onModifyListener(url, redirectUrl);
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`);
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test makes a request against a server and tests redirect to another server page.
+add_task(async function test_extension_302_redirect_web() {
+ function background(serverUrl) {
+ let expectedUrls = ["/redirect", "/dummy"];
+ let expected = [
+ "onBeforeRequest",
+ "onHeadersReceived",
+ "onBeforeRedirect",
+ "onBeforeRequest",
+ "onHeadersReceived",
+ "onResponseStarted",
+ "onCompleted",
+ ];
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertTrue(
+ details.url.includes(expectedUrls.shift()),
+ "onBeforeRequest url matches"
+ );
+ browser.test.assertEq(
+ expected.shift(),
+ "onBeforeRequest",
+ "onBeforeRequest matches"
+ );
+ },
+ { urls: [serverUrl] }
+ );
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ browser.test.assertEq(
+ expected.shift(),
+ "onHeadersReceived",
+ "onHeadersReceived matches"
+ );
+ },
+ { urls: [serverUrl] }
+ );
+ browser.webRequest.onResponseStarted.addListener(
+ details => {
+ browser.test.assertEq(
+ expected.shift(),
+ "onResponseStarted",
+ "onResponseStarted matches"
+ );
+ },
+ { urls: [serverUrl] }
+ );
+ browser.webRequest.onBeforeRedirect.addListener(
+ details => {
+ browser.test.assertTrue(
+ details.redirectUrl.includes("/dummy"),
+ "onBeforeRedirect matches redirectUrl"
+ );
+ browser.test.assertEq(
+ expected.shift(),
+ "onBeforeRedirect",
+ "onBeforeRedirect matches"
+ );
+ },
+ { urls: [serverUrl] }
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.assertTrue(
+ details.url.includes("/dummy"),
+ "onCompleted expected url received"
+ );
+ browser.test.assertEq(
+ expected.shift(),
+ "onCompleted",
+ "onCompleted matches"
+ );
+ browser.test.notifyPass("requestCompleted");
+ },
+ { urls: [serverUrl] }
+ );
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`);
+ browser.test.notifyFail("requestCompleted");
+ },
+ { urls: [serverUrl] }
+ );
+ }
+ let extension = getExtension(
+ false,
+ `(${background})("*://${server.identity.primaryHost}/*")`,
+ false
+ );
+ await extension.startup();
+ let redirectUrl = `${gServerUrl}/dummy`;
+ let completed = extension.awaitFinish("requestCompleted");
+ let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(
+ contentPage.browser.documentURI.spec,
+ redirectUrl,
+ `expected content redirect`
+ );
+ await completed;
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test makes a request against a server and tests redirect to another server page, without
+// onBeforeRedirect. Bug 1448599
+add_task(async function test_extension_302_redirect_opening() {
+ let redirectUrl = `${gServerUrl}/dummy`;
+ let expectData = [
+ {
+ event: "onBeforeRequest",
+ url: `${gServerUrl}/redirect`,
+ },
+ {
+ event: "onBeforeRequest",
+ url: redirectUrl,
+ },
+ ];
+ function background(serverUrl, expected) {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ let expect = expected.shift();
+ browser.test.assertEq(
+ expect.event,
+ "onBeforeRequest",
+ "onBeforeRequest event matches"
+ );
+ browser.test.assertTrue(
+ details.url.startsWith(expect.url),
+ "onBeforeRequest url matches"
+ );
+ if (expected.length === 0) {
+ browser.test.notifyPass("requestCompleted");
+ }
+ },
+ { urls: [serverUrl] }
+ );
+ }
+ let extension = getExtension(
+ false,
+ `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify(
+ expectData
+ )})`,
+ false
+ );
+ await extension.startup();
+ let completed = extension.awaitFinish("requestCompleted");
+ let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(
+ contentPage.browser.documentURI.spec,
+ redirectUrl,
+ `expected content redirect`
+ );
+ await completed;
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test makes a request against a server and tests redirect to another server page, without
+// onBeforeRedirect. Bug 1448599
+add_task(async function test_extension_302_redirect_modify() {
+ let redirectUrl = `${gServerUrl}/dummy`;
+ let expectData = [
+ {
+ event: "onHeadersReceived",
+ url: `${gServerUrl}/redirect`,
+ },
+ {
+ event: "onHeadersReceived",
+ url: redirectUrl,
+ },
+ ];
+ function background(serverUrl, expected) {
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ let expect = expected.shift();
+ browser.test.assertEq(
+ expect.event,
+ "onHeadersReceived",
+ "onHeadersReceived event matches"
+ );
+ browser.test.assertTrue(
+ details.url.startsWith(expect.url),
+ "onHeadersReceived url matches"
+ );
+ if (expected.length === 0) {
+ browser.test.notifyPass("requestCompleted");
+ }
+ },
+ { urls: ["<all_urls>"] }
+ );
+ }
+ let extension = getExtension(
+ false,
+ `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify(
+ expectData
+ )})`,
+ false
+ );
+ await extension.startup();
+ let completed = extension.awaitFinish("requestCompleted");
+ let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(
+ contentPage.browser.documentURI.spec,
+ redirectUrl,
+ `expected content redirect`
+ );
+ await completed;
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test makes a request against a server and tests redirect to another server page, without
+// onBeforeRedirect. Bug 1448599
+add_task(async function test_extension_302_redirect_tracing() {
+ let redirectUrl = `${gServerUrl}/dummy`;
+ let expectData = [
+ {
+ event: "onCompleted",
+ url: redirectUrl,
+ },
+ ];
+ function background(serverUrl, expected) {
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ let expect = expected.shift();
+ browser.test.assertEq(
+ expect.event,
+ "onCompleted",
+ "onCompleted event matches"
+ );
+ browser.test.assertTrue(
+ details.url.startsWith(expect.url),
+ "onCompleted url matches"
+ );
+ if (expected.length === 0) {
+ browser.test.notifyPass("requestCompleted");
+ }
+ },
+ { urls: [serverUrl] }
+ );
+ }
+ let extension = getExtension(
+ false,
+ `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify(
+ expectData
+ )})`,
+ false
+ );
+ await extension.startup();
+ let completed = extension.awaitFinish("requestCompleted");
+ let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(
+ contentPage.browser.documentURI.spec,
+ redirectUrl,
+ `expected content redirect`
+ );
+ await completed;
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test makes a request against a server and tests webrequest. Currently
+// disabled due to NS_BINDING_ABORTED happening.
+add_task(async function test_extension_302_redirect() {
+ let extension = getExtension(true, () => {
+ let myuri = browser.runtime.getURL("*");
+ let exturi = browser.runtime.getURL("finished.html");
+ browser.webRequest.onBeforeRedirect.addListener(
+ details => {
+ browser.test.assertEq(details.redirectUrl, exturi, "redirect matches");
+ },
+ { urls: ["<all_urls>", myuri] }
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.assertEq(details.url, exturi, "expected url received");
+ browser.test.notifyPass("requestCompleted");
+ },
+ { urls: ["<all_urls>", myuri] }
+ );
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`);
+ browser.test.notifyFail("requestCompleted");
+ },
+ { urls: ["<all_urls>", myuri] }
+ );
+ // send the extensions public uri to the test.
+ browser.test.sendMessage("redirectURI", exturi);
+ });
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let completed = extension.awaitFinish("requestCompleted");
+ let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(
+ contentPage.browser.documentURI.spec,
+ redirectUrl,
+ `expected content redirect`
+ );
+ await completed;
+ await contentPage.close();
+ await extension.unload();
+}).skip();
+
+// This test makes a request and uses onBeforeRequet to redirect to moz-ext.
+// Currently disabled due to NS_BINDING_ABORTED happening.
+add_task(async function test_extension_redirect() {
+ let extension = getExtension(true, () => {
+ let myuri = browser.runtime.getURL("*");
+ let exturi = browser.runtime.getURL("finished.html");
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ return { redirectUrl: exturi };
+ },
+ { urls: ["<all_urls>", myuri] },
+ ["blocking"]
+ );
+ browser.webRequest.onBeforeRedirect.addListener(
+ details => {
+ browser.test.assertEq(details.redirectUrl, exturi, "redirect matches");
+ },
+ { urls: ["<all_urls>", myuri] }
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.assertEq(details.url, exturi, "expected url received");
+ browser.test.notifyPass("requestCompleted");
+ },
+ { urls: ["<all_urls>", myuri] }
+ );
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`);
+ browser.test.notifyFail("requestCompleted");
+ },
+ { urls: ["<all_urls>", myuri] }
+ );
+ // send the extensions public uri to the test.
+ browser.test.sendMessage("redirectURI", exturi);
+ });
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let completed = extension.awaitFinish("requestCompleted");
+ let url = `${gServerUrl}/dummy?r=${Math.random()}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`);
+ await completed;
+ await contentPage.close();
+ await extension.unload();
+}).skip();
+
+add_task(async function test_redirect_with_onHeadersReceived() {
+ let redirectUrl = `${gServerUrl}/dummy-2`;
+
+ function background(initialUrl, redirectUrl) {
+ browser.webRequest.onCompleted.addListener(
+ () => {
+ browser.test.notifyPass("requestCompleted");
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.webRequest.onHeadersReceived.addListener(
+ () => {
+ // Redirect to a different URL when we receive the headers of the
+ // initial request.
+ return { redirectUrl };
+ },
+ { urls: [initialUrl] },
+ ["blocking"]
+ );
+ }
+ let extension = getExtension(
+ false,
+ `(${background})("*://${server.identity.primaryHost}/dummy", "${redirectUrl}")`
+ );
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${gServerUrl}/dummy`
+ );
+ await extension.awaitFinish("requestCompleted");
+ equal(contentPage.browser.documentURI.spec, redirectUrl, "expected redirect");
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_no_redirect_with_location_in_onHeadersReceived() {
+ function background(initialUrl, redirectUrl) {
+ browser.webRequest.onCompleted.addListener(
+ ({ responseHeaders }) => {
+ // Make sure that the `Location` header is set by `onHeadersReceived`.
+ browser.test.assertTrue(
+ responseHeaders.some(({ name, value }) => {
+ return name.toLowerCase() === "location" && value === redirectUrl;
+ }),
+ "Location header is set"
+ );
+
+ browser.test.notifyPass("requestCompleted");
+ },
+ { urls: ["<all_urls>"] },
+ ["responseHeaders"]
+ );
+
+ browser.webRequest.onHeadersReceived.addListener(
+ ({ responseHeaders }) => {
+ return {
+ responseHeaders: [
+ ...responseHeaders,
+ // Although we set a Location header here, the request shouldn't be
+ // redirected to `redirectUrl` because the status code hasn't been
+ // change (and cannot be changed from there).
+ { name: "Location", value: redirectUrl },
+ ],
+ };
+ },
+ { urls: [initialUrl] },
+ ["blocking", "responseHeaders"]
+ );
+ }
+ let extension = getExtension(
+ false,
+ `(${background})("*://${server.identity.primaryHost}/dummy", "${gServerUrl}/dummy-2")`
+ );
+ await extension.startup();
+
+ let initialUrl = `${gServerUrl}/dummy`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(initialUrl);
+ await extension.awaitFinish("requestCompleted");
+ equal(
+ contentPage.browser.documentURI.spec,
+ initialUrl,
+ "expected no redirect"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js
new file mode 100644
index 0000000000..e42f45c019
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js
@@ -0,0 +1,26 @@
+/* -*- 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_connect_without_listener() {
+ function background() {
+ let port = browser.runtime.connect();
+ port.onDisconnect.addListener(() => {
+ browser.test.assertEq(
+ "Could not establish connection. Receiving end does not exist.",
+ port.error && port.error.message
+ );
+ browser.test.notifyPass("port.onDisconnect was called");
+ });
+ }
+ let extensionData = {
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitFinish("port.onDisconnect was called");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBackgroundPage.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBackgroundPage.js
new file mode 100644
index 0000000000..5af0bab639
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBackgroundPage.js
@@ -0,0 +1,172 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+add_task(async function setup() {
+ await AddonTestUtils.promiseStartupManager();
+
+ Services.prefs.setBoolPref("dom.serviceWorkers.testing.enabled", true);
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("dom.serviceWorkers.testing.enabled");
+ Services.prefs.clearUserPref("dom.serviceWorkers.idle_timeout");
+ });
+});
+
+add_task(async function test_getBackgroundPage_noBackground() {
+ async function testBackground() {
+ let page = await browser.runtime.getBackgroundPage();
+ browser.test.assertEq(
+ page,
+ null,
+ "getBackgroundPage returned null as expected"
+ );
+ browser.test.sendMessage("page-ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "page.html": `
+ <!DOCTYPE html>
+ <html>
+ <head><meta charset="utf-8"></head>
+ <body>
+ <script src="page.js"></script>
+ </body></html>
+ `,
+
+ "page.js": testBackground,
+ },
+ });
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}//page.html`
+ );
+ await extension.awaitMessage("page-ready");
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+add_task(
+ {
+ pref_set: [["extensions.eventPages.enabled", true]],
+ skip_if: () =>
+ Services.prefs.getBoolPref(
+ "extensions.backgroundServiceWorker.forceInTestExtension",
+ false
+ ),
+ },
+ async function test_getBackgroundPage_eventpage() {
+ async function wakeupBackground() {
+ let page = await browser.runtime.getBackgroundPage();
+ page.hello();
+ browser.test.sendMessage("page-ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary", // To automatically show sidebar on load.
+ manifest: {
+ background: { persistent: false },
+ },
+
+ files: {
+ "page.html": `
+ <!DOCTYPE html>
+ <html>
+ <head><meta charset="utf-8"></head>
+ <body>
+ <script src="page.js"></script>
+ </body></html>
+ `,
+
+ "page.js": wakeupBackground,
+ },
+ async background() {
+ // eslint-disable-next-line no-unused-vars
+ window.hello = () => {
+ browser.test.sendMessage("hello");
+ };
+
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ await extension.terminateBackground();
+
+ // wake up the background
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}//page.html`
+ );
+ await extension.awaitMessage("ready");
+ await extension.awaitMessage("hello");
+ await extension.awaitMessage("page-ready");
+ await contentPage.close();
+
+ ok(true, "getBackgroundPage wakes up background");
+
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () => {
+ return !WebExtensionPolicy.backgroundServiceWorkerEnabled;
+ },
+ },
+ async function test_getBackgroundPage_serviceWorker() {
+ async function testBackground() {
+ let page = await browser.runtime.getBackgroundPage();
+ browser.test.assertEq(
+ page,
+ null,
+ "getBackgroundPage returned null as expected"
+ );
+ browser.test.sendMessage("page-ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ version: "1.0",
+ background: {
+ service_worker: "sw.js",
+ },
+ browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } },
+ },
+
+ files: {
+ "sw.js": "dump('Background ServiceWorker - executed\\n');",
+ "page.html": `
+ <!DOCTYPE html>
+ <html>
+ <head><meta charset="utf-8"></head>
+ <body>
+ <script src="page.js"></script>
+ </body></html>
+ `,
+
+ "page.js": testBackground,
+ },
+ });
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}//page.html`
+ );
+ await extension.awaitMessage("page-ready");
+ await contentPage.close();
+
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js
new file mode 100644
index 0000000000..3f3b8f8e95
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.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/. */
+"use strict";
+
+add_task(async function setup() {
+ ExtensionTestUtils.mockAppInfo();
+});
+
+add_task(async function test_getBrowserInfo() {
+ async function background() {
+ let info = await browser.runtime.getBrowserInfo();
+
+ browser.test.assertEq(info.name, "XPCShell", "name is valid");
+ browser.test.assertEq(info.vendor, "Mozilla", "vendor is Mozilla");
+ browser.test.assertEq(info.version, "48", "version is correct");
+ browser.test.assertEq(info.buildID, "20160315", "buildID is correct");
+
+ browser.test.notifyPass("runtime.getBrowserInfo");
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({ background });
+ await extension.startup();
+ await extension.awaitFinish("runtime.getBrowserInfo");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js
new file mode 100644
index 0000000000..7d0dde2f8a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js
@@ -0,0 +1,36 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+function backgroundScript() {
+ browser.runtime.getPlatformInfo(info => {
+ let validOSs = ["mac", "win", "android", "cros", "linux", "openbsd"];
+ let validArchs = [
+ "aarch64",
+ "arm",
+ "ppc64",
+ "s390x",
+ "sparc64",
+ "x86-32",
+ "x86-64",
+ ];
+
+ browser.test.assertTrue(validOSs.includes(info.os), "OS is valid");
+ browser.test.assertTrue(
+ validArchs.includes(info.arch),
+ "Architecture is valid"
+ );
+ browser.test.notifyPass("runtime.getPlatformInfo");
+ });
+}
+
+let extensionData = {
+ background: backgroundScript,
+};
+
+add_task(async function () {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitFinish("runtime.getPlatformInfo");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js
new file mode 100644
index 0000000000..6967e81232
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js
@@ -0,0 +1,46 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_runtime_id() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ background() {
+ browser.test.sendMessage("background-id", browser.runtime.id);
+ },
+
+ files: {
+ "content_script.js"() {
+ browser.test.sendMessage("content-id", browser.runtime.id);
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ let backgroundId = await extension.awaitMessage("background-id");
+ equal(
+ backgroundId,
+ extension.id,
+ "runtime.id from background script is correct"
+ );
+
+ let contentId = await extension.awaitMessage("content-id");
+ equal(contentId, extension.id, "runtime.id from content script is correct");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js
new file mode 100644
index 0000000000..254387dc6b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js
@@ -0,0 +1,84 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(
+ async function test_messaging_to_self_should_not_trigger_onMessage_onConnect() {
+ async function background() {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.assertEq("msg from child", msg);
+ browser.test.sendMessage(
+ "sendMessage did not call same-frame onMessage"
+ );
+ });
+
+ browser.test.onMessage.addListener(msg => {
+ browser.test.assertEq(
+ "sendMessage with a listener in another frame",
+ msg
+ );
+ browser.runtime.sendMessage("should only reach another frame");
+ });
+
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage("should not trigger same-frame onMessage"),
+ "Could not establish connection. Receiving end does not exist."
+ );
+
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertEq("from-frame", port.name);
+ browser.runtime.connect({ name: "from-bg-2" });
+ });
+
+ await new Promise(resolve => {
+ let port = browser.runtime.connect({ name: "from-bg-1" });
+ port.onDisconnect.addListener(() => {
+ browser.test.assertEq(
+ "Could not establish connection. Receiving end does not exist.",
+ port.error.message
+ );
+ resolve();
+ });
+ });
+
+ let anotherFrame = document.createElement("iframe");
+ anotherFrame.src = browser.runtime.getURL("extensionpage.html");
+ document.body.appendChild(anotherFrame);
+ }
+
+ function lastScript() {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.assertEq("should only reach another frame", msg);
+ browser.runtime.sendMessage("msg from child");
+ });
+ browser.test.sendMessage("sendMessage callback called");
+
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertEq("from-bg-2", port.name);
+ browser.test.sendMessage("connect did not call same-frame onConnect");
+ });
+ browser.runtime.connect({ name: "from-frame" });
+ }
+
+ let extensionData = {
+ background,
+ files: {
+ "lastScript.js": lastScript,
+ "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="lastScript.js"></script>`,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitMessage("sendMessage callback called");
+ extension.sendMessage("sendMessage with a listener in another frame");
+
+ await Promise.all([
+ extension.awaitMessage("connect did not call same-frame onConnect"),
+ extension.awaitMessage("sendMessage did not call same-frame onMessage"),
+ ]);
+
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js
new file mode 100644
index 0000000000..c330aaafde
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js
@@ -0,0 +1,599 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+
+const {
+ createAppInfo,
+ createTempWebExtensionFile,
+ promiseAddonEvent,
+ promiseCompleteAllInstalls,
+ promiseFindAddonUpdates,
+ promiseRestartManager,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+// Allow for unsigned addons.
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+
+function background() {
+ let onInstalledDetails = null;
+ let onStartupFired = false;
+ let eventPage = browser.runtime.getManifest().background.persistent === false;
+
+ browser.runtime.onInstalled.addListener(details => {
+ onInstalledDetails = details;
+ });
+
+ browser.runtime.onStartup.addListener(() => {
+ onStartupFired = true;
+ });
+
+ browser.test.onMessage.addListener(message => {
+ if (message === "get-on-installed-details") {
+ onInstalledDetails = onInstalledDetails || { fired: false };
+ browser.test.sendMessage("on-installed-details", onInstalledDetails);
+ } else if (message === "did-on-startup-fire") {
+ browser.test.sendMessage("on-startup-fired", onStartupFired);
+ } else if (message === "reload-extension") {
+ browser.runtime.reload();
+ }
+ });
+
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ browser.test.sendMessage("reloading");
+ browser.runtime.reload();
+ });
+
+ if (eventPage) {
+ browser.runtime.onSuspend.addListener(() => {
+ browser.test.sendMessage("suspended");
+ });
+ // an event we use to restart the background
+ browser.browserSettings.homepageOverride.onChange.addListener(() => {
+ browser.test.sendMessage("homepageOverride");
+ });
+ }
+}
+
+async function expectEvents(
+ extension,
+ {
+ onStartupFired,
+ onInstalledFired,
+ onInstalledReason,
+ onInstalledTemporary,
+ onInstalledPrevious,
+ }
+) {
+ extension.sendMessage("get-on-installed-details");
+ let details = await extension.awaitMessage("on-installed-details");
+ if (onInstalledFired) {
+ equal(
+ details.reason,
+ onInstalledReason,
+ "runtime.onInstalled fired with the correct reason"
+ );
+ equal(
+ details.temporary,
+ onInstalledTemporary,
+ "runtime.onInstalled fired with the correct temporary flag"
+ );
+ if (onInstalledPrevious) {
+ equal(
+ details.previousVersion,
+ onInstalledPrevious,
+ "runtime.onInstalled after update with correct previousVersion"
+ );
+ }
+ } else {
+ equal(
+ details.fired,
+ onInstalledFired,
+ "runtime.onInstalled should not have fired"
+ );
+ }
+
+ extension.sendMessage("did-on-startup-fire");
+ let fired = await extension.awaitMessage("on-startup-fired");
+ equal(
+ fired,
+ onStartupFired,
+ `Expected runtime.onStartup to ${onStartupFired ? "" : "not "} fire`
+ );
+}
+
+add_task(async function test_should_fire_on_addon_update() {
+ Preferences.set("extensions.logging.enabled", false);
+
+ await promiseStartupManager();
+
+ const EXTENSION_ID =
+ "test_runtime_on_installed_addon_update@tests.mozilla.org";
+
+ const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
+
+ // The test extension uses an insecure update url.
+ Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+ const testServer = createHttpServer();
+ const port = testServer.identity.primaryPort;
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ update_url: `http://localhost:${port}/test_update.json`,
+ },
+ },
+ },
+ background,
+ });
+
+ testServer.registerPathHandler("/test_update.json", (request, response) => {
+ response.write(`{
+ "addons": {
+ "${EXTENSION_ID}": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://localhost:${port}/addons/test_runtime_on_installed-2.0.xpi"
+ }
+ ]
+ }
+ }
+ }`);
+ });
+
+ let webExtensionFile = createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ });
+
+ testServer.registerFile(
+ "/addons/test_runtime_on_installed-2.0.xpi",
+ webExtensionFile
+ );
+
+ await extension.startup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "install",
+ });
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+ equal(addon.version, "1.0", "The installed addon has the correct version");
+
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+
+ let promiseInstalled = promiseAddonEvent("onInstalled");
+ await promiseCompleteAllInstalls([install]);
+
+ await extension.awaitMessage("reloading");
+
+ let [updated_addon] = await promiseInstalled;
+ equal(
+ updated_addon.version,
+ "2.0",
+ "The updated addon has the correct version"
+ );
+
+ await extension.awaitStartup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "update",
+ onInstalledPrevious: "1.0",
+ });
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_should_fire_on_browser_update() {
+ const EXTENSION_ID =
+ "test_runtime_on_installed_browser_update@tests.mozilla.org";
+
+ await promiseStartupManager("1");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "install",
+ });
+
+ // Restart the browser.
+ await promiseRestartManager("1");
+ await extension.awaitBackgroundStarted();
+
+ await expectEvents(extension, {
+ onStartupFired: true,
+ onInstalledFired: false,
+ });
+
+ // Update the browser.
+ await promiseRestartManager("2");
+ await extension.awaitBackgroundStarted();
+
+ await expectEvents(extension, {
+ onStartupFired: true,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "browser_update",
+ });
+
+ // Restart the browser.
+ await promiseRestartManager("2");
+ await extension.awaitBackgroundStarted();
+
+ await expectEvents(extension, {
+ onStartupFired: true,
+ onInstalledFired: false,
+ });
+
+ // Update the browser again.
+ await promiseRestartManager("3");
+ await extension.awaitBackgroundStarted();
+
+ await expectEvents(extension, {
+ onStartupFired: true,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "browser_update",
+ });
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_should_not_fire_on_reload() {
+ const EXTENSION_ID = "test_runtime_on_installed_reload@tests.mozilla.org";
+
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "install",
+ });
+
+ extension.sendMessage("reload-extension");
+ extension.setRestarting();
+ await extension.awaitStartup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: false,
+ });
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+add_task(async function test_should_not_fire_on_restart() {
+ const EXTENSION_ID = "test_runtime_on_installed_restart@tests.mozilla.org";
+
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "install",
+ });
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+ await addon.disable();
+ await addon.enable();
+ await extension.awaitStartup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: false,
+ });
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+add_task(async function test_temporary_installation() {
+ const EXTENSION_ID =
+ "test_runtime_on_installed_addon_temporary@tests.mozilla.org";
+
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledReason: "install",
+ onInstalledTemporary: true,
+ });
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+add_task(
+ {
+ pref_set: [["extensions.eventPages.enabled", true]],
+ },
+ async function test_runtime_eventpage() {
+ const EXTENSION_ID = "test_runtime_eventpage@tests.mozilla.org";
+
+ await promiseStartupManager("1");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ permissions: ["browserSettings"],
+ background: {
+ persistent: false,
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledReason: "install",
+ onInstalledTemporary: false,
+ });
+
+ info(`test onInstall does not fire after suspend`);
+ // we do enough here that idle timeout causes intermittent failure.
+ // using terminateBackground results in the same code path tested.
+ extension.terminateBackground();
+ await extension.awaitMessage("suspended");
+ await promiseExtensionEvent(extension, "shutdown-background-script");
+
+ Services.prefs.setStringPref(
+ "browser.startup.homepage",
+ "http://test.example.com"
+ );
+ await extension.awaitMessage("homepageOverride");
+ // onStartup remains persisted, but not primed
+ assertPersistentListeners(extension, "runtime", "onStartup", {
+ primed: false,
+ persisted: true,
+ });
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: false,
+ });
+
+ info("test onStartup is not primed but background starts automatically");
+ await promiseRestartManager();
+ // onStartup is a bit special. During APP_STARTUP we do not
+ // prime this, we just start since it needs to.
+ assertPersistentListeners(extension, "runtime", "onStartup", {
+ primed: false,
+ persisted: true,
+ });
+ await extension.awaitBackgroundStarted();
+
+ info("test expectEvents");
+ await expectEvents(extension, {
+ onStartupFired: true,
+ onInstalledFired: false,
+ });
+
+ info("test onInstalled fired during browser update");
+ await promiseRestartManager("2");
+ assertPersistentListeners(extension, "runtime", "onStartup", {
+ primed: false,
+ persisted: true,
+ });
+ await extension.awaitBackgroundStarted();
+
+ await expectEvents(extension, {
+ onStartupFired: true,
+ onInstalledFired: true,
+ onInstalledReason: "browser_update",
+ onInstalledTemporary: false,
+ });
+
+ info(`test onStarted does not fire after suspend`);
+ extension.terminateBackground();
+ await extension.awaitMessage("suspended");
+ await promiseExtensionEvent(extension, "shutdown-background-script");
+
+ Services.prefs.setStringPref(
+ "browser.startup.homepage",
+ "http://homepage.example.com"
+ );
+ await extension.awaitMessage("homepageOverride");
+ // onStartup remains persisted, but not primed
+ assertPersistentListeners(extension, "runtime", "onStartup", {
+ primed: false,
+ persisted: true,
+ });
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: false,
+ });
+
+ await extension.unload();
+ await promiseShutdownManager();
+ }
+);
+
+// Verify we don't regress the issue related to runtime.onStartup persistent
+// listener being cleared from the startup data as part of priming all listeners
+// while terminating the event page on idle timeout (Bug 1796586).
+add_task(
+ {
+ pref_set: [["extensions.eventPages.enabled", true]],
+ },
+ async function test_runtime_onStartup_eventpage() {
+ const EXTENSION_ID = "test_eventpage_onStartup@tests.mozilla.org";
+
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ permissions: ["browserSettings"],
+ background: {
+ persistent: false,
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledReason: "install",
+ onInstalledTemporary: false,
+ });
+
+ info("Simulated idle timeout");
+ extension.terminateBackground();
+ await extension.awaitMessage("suspended");
+ await promiseExtensionEvent(extension, "shutdown-background-script");
+
+ // onStartup remains persisted, but not primed
+ assertPersistentListeners(extension, "runtime", "onStartup", {
+ primed: false,
+ persisted: true,
+ });
+
+ info(`test onStartup after restart`);
+ await promiseRestartManager();
+
+ // onStartup is a bit special. During APP_STARTUP we do not
+ // prime this, we just start since it needs to.
+ assertPersistentListeners(extension, "runtime", "onStartup", {
+ primed: false,
+ persisted: true,
+ });
+ await extension.awaitBackgroundStarted();
+
+ info("test expectEvents");
+ await expectEvents(extension, {
+ onStartupFired: true,
+ onInstalledFired: false,
+ });
+
+ extension.terminateBackground();
+ await extension.awaitMessage("suspended");
+ await promiseExtensionEvent(extension, "shutdown-background-script");
+ assertPersistentListeners(extension, "runtime", "onStartup", {
+ primed: false,
+ persisted: true,
+ });
+
+ await extension.unload();
+ await promiseShutdownManager();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js
new file mode 100644
index 0000000000..7365a13f93
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js
@@ -0,0 +1,69 @@
+"use strict";
+
+add_task(async function test_port_disconnected_from_wrong_window() {
+ let extensionData = {
+ background() {
+ let num = 0;
+ let ports = {};
+ let done = false;
+
+ browser.runtime.onConnect.addListener(port => {
+ num++;
+ ports[num] = port;
+
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, "port-2-response", "Got port 2 response");
+ browser.test.sendMessage(msg + "-received");
+ done = true;
+ });
+
+ port.onDisconnect.addListener(err => {
+ if (port === ports[1]) {
+ browser.test.log("Port 1 disconnected, sending message via port 2");
+ ports[2].postMessage("port-2-msg");
+ } else {
+ browser.test.assertTrue(
+ done,
+ "Port 2 disconnected only after a full roundtrip received"
+ );
+ }
+ });
+
+ browser.test.sendMessage("port-connect-" + num);
+ });
+ },
+ files: {
+ "page.html": `
+ <!DOCTYPE html><meta charset="utf8">
+ <script src="script.js"></script>
+ `,
+ "script.js"() {
+ let port = browser.runtime.connect();
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, "port-2-msg", "Got message via port 2");
+ port.postMessage("port-2-response");
+ });
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ let url = `moz-extension://${extension.uuid}/page.html`;
+ await extension.startup();
+
+ let page1 = await ExtensionTestUtils.loadContentPage(url, { extension });
+ await extension.awaitMessage("port-connect-1");
+ info("First page opened port 1");
+
+ let page2 = await ExtensionTestUtils.loadContentPage(url, { extension });
+ await extension.awaitMessage("port-connect-2");
+ info("Second page opened port 2");
+
+ info("Closing the first page should not close port 2");
+ await page1.close();
+ await extension.awaitMessage("port-2-response-received");
+ info("Roundtrip message through port 2 received");
+
+ await page2.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js
new file mode 100644
index 0000000000..dd47744a97
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js
@@ -0,0 +1,170 @@
+"use strict";
+
+let gcExperimentAPIs = {
+ gcHelper: {
+ schema: "schema.json",
+ child: {
+ scopes: ["addon_child"],
+ script: "child.js",
+ paths: [["gcHelper"]],
+ },
+ },
+};
+
+let gcExperimentFiles = {
+ "schema.json": JSON.stringify([
+ {
+ namespace: "gcHelper",
+ functions: [
+ {
+ name: "forceGarbageCollect",
+ type: "function",
+ parameters: [],
+ async: true,
+ },
+ {
+ name: "registerWitness",
+ type: "function",
+ parameters: [
+ {
+ name: "obj",
+ // Expected type is "object", but using "any" here to ensure that
+ // the parameter is untouched (not normalized).
+ type: "any",
+ },
+ ],
+ returns: { type: "number" },
+ },
+ {
+ name: "isGarbageCollected",
+ type: "function",
+ parameters: [
+ {
+ name: "witnessId",
+ description: "return value of registerWitness",
+ type: "number",
+ },
+ ],
+ returns: { type: "boolean" },
+ },
+ ],
+ },
+ ]),
+ "child.js": () => {
+ let { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+ );
+ /* globals ExtensionAPI */
+ this.gcHelper = class extends ExtensionAPI {
+ getAPI(context) {
+ let witnesses = new Map();
+ return {
+ gcHelper: {
+ async forceGarbageCollect() {
+ // Logic copied from test_ext_contexts_gc.js
+ for (let i = 0; i < 3; ++i) {
+ Cu.forceShrinkingGC();
+ Cu.forceCC();
+ Cu.forceGC();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+ },
+ registerWitness(obj) {
+ let witnessId = witnesses.size;
+ witnesses.set(witnessId, Cu.getWeakReference(obj));
+ return witnessId;
+ },
+ isGarbageCollected(witnessId) {
+ return witnesses.get(witnessId).get() === null;
+ },
+ },
+ };
+ }
+ };
+ },
+};
+
+// Verify that the experiment is working as intended before using it in tests.
+add_task(async function test_gc_experiment() {
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ manifest: {
+ experiment_apis: gcExperimentAPIs,
+ },
+ files: gcExperimentFiles,
+ async background() {
+ let obj1 = {};
+ let obj2 = {};
+ let witness1 = browser.gcHelper.registerWitness(obj1);
+ let witness2 = browser.gcHelper.registerWitness(obj2);
+ obj1 = null;
+ await browser.gcHelper.forceGarbageCollect();
+ browser.test.assertTrue(
+ browser.gcHelper.isGarbageCollected(witness1),
+ "obj1 should have been garbage-collected"
+ );
+ browser.test.assertFalse(
+ browser.gcHelper.isGarbageCollected(witness2),
+ "obj2 should not have been garbage-collected"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_port_gc() {
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ manifest: {
+ experiment_apis: gcExperimentAPIs,
+ },
+ files: gcExperimentFiles,
+ async background() {
+ let witnessPortSender;
+ let witnessPortReceiver;
+
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertEq("daName", port.name, "expected port");
+ witnessPortReceiver = browser.gcHelper.registerWitness(port);
+ port.disconnect();
+ });
+
+ // runtime.connect() only triggers onConnect for different contexts,
+ // so create a frame to have a different context.
+ // A blank frame in a moz-extension:-document will have access to the
+ // extension APIs.
+ let frameWindow = await new Promise(resolve => {
+ let f = document.createElement("iframe");
+ f.onload = () => resolve(f.contentWindow);
+ document.body.append(f);
+ });
+ await new Promise(resolve => {
+ let port = frameWindow.browser.runtime.connect({ name: "daName" });
+ witnessPortSender = browser.gcHelper.registerWitness(port);
+ port.onDisconnect.addListener(() => resolve());
+ });
+
+ await browser.gcHelper.forceGarbageCollect();
+
+ browser.test.assertTrue(
+ browser.gcHelper.isGarbageCollected(witnessPortSender),
+ "runtime.connect() port should have been garbage-collected"
+ );
+ browser.test.assertTrue(
+ browser.gcHelper.isGarbageCollected(witnessPortReceiver),
+ "runtime.onConnect port should have been garbage-collected"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js
new file mode 100644
index 0000000000..2bbc9864d7
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js
@@ -0,0 +1,462 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function runtimeSendMessageReply() {
+ function background() {
+ browser.runtime.onMessage.addListener((msg, sender, respond) => {
+ if (msg == "respond-now") {
+ respond(msg);
+ } else if (msg == "respond-soon") {
+ setTimeout(() => {
+ respond(msg);
+ }, 0);
+ return true;
+ } else if (msg == "respond-promise") {
+ return Promise.resolve(msg);
+ } else if (msg == "respond-promise-false") {
+ return Promise.resolve(false);
+ } else if (msg == "respond-false") {
+ // return false means that respond() is not expected to be called.
+ setTimeout(() => respond("should be ignored"));
+ return false;
+ } else if (msg == "respond-never") {
+ return undefined;
+ } else if (msg == "respond-error") {
+ return Promise.reject(new Error(msg));
+ } else if (msg == "throw-error") {
+ throw new Error(msg);
+ } else if (msg === "respond-uncloneable") {
+ return Promise.resolve(window);
+ } else if (msg === "reject-uncloneable") {
+ return Promise.reject(window);
+ } else if (msg == "reject-undefined") {
+ return Promise.reject();
+ } else if (msg == "throw-undefined") {
+ throw undefined; // eslint-disable-line no-throw-literal
+ }
+ });
+
+ browser.runtime.onMessage.addListener((msg, sender, respond) => {
+ if (msg == "respond-now") {
+ respond("hello");
+ } else if (msg == "respond-now-2") {
+ respond(msg);
+ }
+ });
+
+ browser.runtime.onMessage.addListener((msg, sender, respond) => {
+ if (msg == "respond-now") {
+ // If a response from another listener is received first, this
+ // exception should be ignored. Test fails if it is not.
+
+ // All this is of course stupid, but some extensions depend on it.
+ msg.blah.this.throws();
+ }
+ });
+
+ let childFrame = document.createElement("iframe");
+ childFrame.src = "extensionpage.html";
+ document.body.appendChild(childFrame);
+ }
+
+ function senderScript() {
+ Promise.all([
+ browser.runtime.sendMessage("respond-now"),
+ browser.runtime.sendMessage("respond-now-2"),
+ new Promise(resolve =>
+ browser.runtime.sendMessage("respond-soon", resolve)
+ ),
+ browser.runtime.sendMessage("respond-promise"),
+ browser.runtime.sendMessage("respond-promise-false"),
+ browser.runtime.sendMessage("respond-false"),
+ browser.runtime.sendMessage("respond-never"),
+ new Promise(resolve => {
+ browser.runtime.sendMessage("respond-never", response => {
+ resolve(response);
+ });
+ }),
+
+ browser.runtime
+ .sendMessage("respond-error")
+ .catch(error => Promise.resolve({ error })),
+ browser.runtime
+ .sendMessage("throw-error")
+ .catch(error => Promise.resolve({ error })),
+
+ browser.runtime
+ .sendMessage("respond-uncloneable")
+ .catch(error => Promise.resolve({ error })),
+ browser.runtime
+ .sendMessage("reject-uncloneable")
+ .catch(error => Promise.resolve({ error })),
+ browser.runtime
+ .sendMessage("reject-undefined")
+ .catch(error => Promise.resolve({ error })),
+ browser.runtime
+ .sendMessage("throw-undefined")
+ .catch(error => Promise.resolve({ error })),
+ ])
+ .then(
+ ([
+ respondNow,
+ respondNow2,
+ respondSoon,
+ respondPromise,
+ respondPromiseFalse,
+ respondFalse,
+ respondNever,
+ respondNever2,
+ respondError,
+ throwError,
+ respondUncloneable,
+ rejectUncloneable,
+ rejectUndefined,
+ throwUndefined,
+ ]) => {
+ browser.test.assertEq(
+ "respond-now",
+ respondNow,
+ "Got the expected immediate response"
+ );
+ browser.test.assertEq(
+ "respond-now-2",
+ respondNow2,
+ "Got the expected immediate response from the second listener"
+ );
+ browser.test.assertEq(
+ "respond-soon",
+ respondSoon,
+ "Got the expected delayed response"
+ );
+ browser.test.assertEq(
+ "respond-promise",
+ respondPromise,
+ "Got the expected promise response"
+ );
+ browser.test.assertEq(
+ false,
+ respondPromiseFalse,
+ "Got the expected false value as a promise result"
+ );
+ browser.test.assertEq(
+ undefined,
+ respondFalse,
+ "Got the expected no-response when onMessage returns false"
+ );
+ browser.test.assertEq(
+ undefined,
+ respondNever,
+ "Got the expected no-response resolution"
+ );
+ browser.test.assertEq(
+ undefined,
+ respondNever2,
+ "Got the expected no-response resolution"
+ );
+
+ browser.test.assertEq(
+ "respond-error",
+ respondError.error.message,
+ "Got the expected error response"
+ );
+ browser.test.assertEq(
+ "throw-error",
+ throwError.error.message,
+ "Got the expected thrown error response"
+ );
+
+ browser.test.assertEq(
+ "Could not establish connection. Receiving end does not exist.",
+ respondUncloneable.error.message,
+ "An uncloneable response should be ignored"
+ );
+ browser.test.assertEq(
+ "An unexpected error occurred",
+ rejectUncloneable.error.message,
+ "Got the expected error for a rejection with an uncloneable value"
+ );
+ browser.test.assertEq(
+ "An unexpected error occurred",
+ rejectUndefined.error.message,
+ "Got the expected error for a void rejection"
+ );
+ browser.test.assertEq(
+ "An unexpected error occurred",
+ throwUndefined.error.message,
+ "Got the expected error for a void throw"
+ );
+
+ browser.test.notifyPass("sendMessage");
+ }
+ )
+ .catch(e => {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ browser.test.notifyFail("sendMessage");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ files: {
+ "senderScript.js": senderScript,
+ "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("sendMessage");
+ await extension.unload();
+});
+
+add_task(async function runtimeSendMessageBlob() {
+ function background() {
+ browser.runtime.onMessage.addListener(msg => {
+ // eslint-disable-next-line mozilla/use-isInstance -- this function runs in an extension
+ browser.test.assertTrue(msg.blob instanceof Blob, "Message is a blob");
+ return Promise.resolve(msg);
+ });
+
+ let childFrame = document.createElement("iframe");
+ childFrame.src = "extensionpage.html";
+ document.body.appendChild(childFrame);
+ }
+
+ function senderScript() {
+ browser.runtime
+ .sendMessage({ blob: new Blob(["hello"]) })
+ .then(response => {
+ browser.test.assertTrue(
+ // eslint-disable-next-line mozilla/use-isInstance -- this function runs in an extension
+ response.blob instanceof Blob,
+ "Response is a blob"
+ );
+ browser.test.notifyPass("sendBlob");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ files: {
+ "senderScript.js": senderScript,
+ "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("sendBlob");
+ await extension.unload();
+});
+
+add_task(async function sendMessageResponseGC() {
+ function background() {
+ let savedResolve, savedRespond;
+
+ browser.runtime.onMessage.addListener((msg, _, respond) => {
+ browser.test.log(`Got request: ${msg}`);
+ switch (msg) {
+ case "ping":
+ respond("pong");
+ return;
+
+ case "promise-save":
+ return new Promise(resolve => {
+ savedResolve = resolve;
+ });
+ case "promise-resolve":
+ savedResolve("saved-resolve");
+ return;
+ case "promise-never":
+ return new Promise(r => {});
+
+ case "callback-save":
+ savedRespond = respond;
+ return true;
+ case "callback-call":
+ savedRespond("saved-respond");
+ return;
+ case "callback-never":
+ return true;
+ }
+ });
+
+ const frame = document.createElement("iframe");
+ frame.src = "page.html";
+ document.body.appendChild(frame);
+ }
+
+ function page() {
+ browser.test.onMessage.addListener(msg => {
+ browser.runtime.sendMessage(msg).then(
+ response => {
+ if (response) {
+ browser.test.log(`Got response: ${response}`);
+ browser.test.sendMessage(response);
+ }
+ },
+ error => {
+ browser.test.assertEq(
+ "Promised response from onMessage listener went out of scope",
+ error.message,
+ `Promise rejected with the correct error message`
+ );
+
+ browser.test.assertTrue(
+ /^moz-extension:\/\/[\w-]+\/%7B[\w-]+%7D\.js/.test(error.fileName),
+ `Promise rejected with the correct error filename: ${error.fileName}`
+ );
+
+ browser.test.assertEq(
+ 4,
+ error.lineNumber,
+ `Promise rejected with the correct error line number`
+ );
+
+ browser.test.assertTrue(
+ /moz-extension:\/\/[\w-]+\/%7B[\w-]+%7D\.js:4/.test(error.stack),
+ `Promise rejected with the correct error stack: ${error.stack}`
+ );
+ browser.test.sendMessage("rejected");
+ }
+ );
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ files: {
+ "page.html":
+ "<!DOCTYPE html><meta charset=utf-8><script src=page.js></script>",
+ "page.js": page,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ // Setup long-running tasks before GC.
+ extension.sendMessage("promise-save");
+ extension.sendMessage("callback-save");
+
+ // Test returning a Promise that can never resolve.
+ extension.sendMessage("promise-never");
+
+ extension.sendMessage("ping");
+ await extension.awaitMessage("pong");
+
+ Services.prefs.setBoolPref(
+ "security.allow_parent_unrestricted_js_loads",
+ true
+ );
+ Services.ppmm.loadProcessScript("data:,Components.utils.forceGC()", false);
+ await extension.awaitMessage("rejected");
+
+ // Test returning `true` without holding the response handle.
+ extension.sendMessage("callback-never");
+
+ extension.sendMessage("ping");
+ await extension.awaitMessage("pong");
+
+ Services.ppmm.loadProcessScript("data:,Components.utils.forceGC()", false);
+ Services.prefs.setBoolPref(
+ "security.allow_parent_unrestricted_js_loads",
+ false
+ );
+ await extension.awaitMessage("rejected");
+
+ // Test that promises from long-running tasks didn't get GCd.
+ extension.sendMessage("promise-resolve");
+ await extension.awaitMessage("saved-resolve");
+
+ extension.sendMessage("callback-call");
+ await extension.awaitMessage("saved-respond");
+
+ ok("Long running tasks responded");
+ await extension.unload();
+});
+
+add_task(async function sendMessage_async_response_multiple_contexts() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.runtime.onMessage.addListener((msg, _, respond) => {
+ browser.test.log(`Background got request: ${msg}`);
+
+ switch (msg) {
+ case "ask-bg-fast":
+ respond("bg-respond");
+ return true;
+
+ case "ask-bg-slow":
+ return new Promise(r => setTimeout(() => r("bg-promise")), 1000);
+ }
+ });
+ browser.test.sendMessage("bg-ready");
+ },
+
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: ["cs.js"],
+ },
+ ],
+ },
+
+ files: {
+ "page.html":
+ "<!DOCTYPE html><meta charset=utf-8><script src=page.js></script>",
+ "page.js"() {
+ browser.runtime.onMessage.addListener((msg, _, respond) => {
+ browser.test.log(`Page got request: ${msg}`);
+
+ switch (msg) {
+ case "ask-page-fast":
+ respond("page-respond");
+ return true;
+
+ case "ask-page-slow":
+ return new Promise(r => setTimeout(() => r("page-promise")), 500);
+ }
+ });
+ browser.test.sendMessage("page-ready");
+ },
+
+ "cs.js"() {
+ Promise.all([
+ browser.runtime.sendMessage("ask-bg-fast"),
+ browser.runtime.sendMessage("ask-bg-slow"),
+ browser.runtime.sendMessage("ask-page-fast"),
+ browser.runtime.sendMessage("ask-page-slow"),
+ ]).then(responses => {
+ browser.test.assertEq(
+ responses.join(),
+ ["bg-respond", "bg-promise", "page-respond", "page-promise"].join(),
+ "Got all expected responses from correct contexts"
+ );
+ browser.test.notifyPass("cs-done");
+ });
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bg-ready");
+
+ let url = `moz-extension://${extension.uuid}/page.html`;
+ let page = await ExtensionTestUtils.loadContentPage(url, { extension });
+ await extension.awaitMessage("page-ready");
+
+ let content = await ExtensionTestUtils.loadContentPage(
+ BASE_URL + "/file_sample.html"
+ );
+ await extension.awaitFinish("cs-done");
+ await content.close();
+
+ await page.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js
new file mode 100644
index 0000000000..2c0b889ba3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js
@@ -0,0 +1,118 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function () {
+ const ID1 = "sendMessage1@tests.mozilla.org";
+ const ID2 = "sendMessage2@tests.mozilla.org";
+
+ let extension1 = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.onMessage.addListener((...args) => {
+ browser.runtime.sendMessage(...args);
+ });
+
+ let frame = document.createElement("iframe");
+ frame.src = "page.html";
+ document.body.appendChild(frame);
+ },
+ manifest: { browser_specific_settings: { gecko: { id: ID1 } } },
+ files: {
+ "page.js": function () {
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ browser.test.sendMessage("received-page", { msg, sender });
+ });
+ // Let them know we're done loading the page.
+ browser.test.sendMessage("page-ready");
+ },
+ "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`,
+ },
+ });
+
+ let extension2 = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.runtime.onMessageExternal.addListener((msg, sender) => {
+ browser.test.sendMessage("received-external", { msg, sender });
+ });
+ },
+ manifest: { browser_specific_settings: { gecko: { id: ID2 } } },
+ });
+
+ await Promise.all([extension1.startup(), extension2.startup()]);
+ await extension1.awaitMessage("page-ready");
+
+ // Check that a message was sent within extension1.
+ async function checkLocalMessage(msg) {
+ let result = await extension1.awaitMessage("received-page");
+ deepEqual(result.msg, msg, "Received internal message");
+ equal(result.sender.id, ID1, "Received correct sender id");
+ }
+
+ // Check that a message was sent from extension1 to extension2.
+ async function checkRemoteMessage(msg) {
+ let result = await extension2.awaitMessage("received-external");
+ deepEqual(result.msg, msg, "Received cross-extension message");
+ equal(result.sender.id, ID1, "Received correct sender id");
+ }
+
+ // sendMessage() takes 3 arguments:
+ // optional extensionID
+ // mandatory message
+ // optional options
+ // Due to this insane design we parse its arguments manually. This
+ // test is meant to cover all the combinations.
+
+ // A single null or undefined argument is allowed, and represents the message
+ extension1.sendMessage(null);
+ await checkLocalMessage(null);
+
+ // With one argument, it must be just the message
+ extension1.sendMessage("message");
+ await checkLocalMessage("message");
+
+ // With two arguments, these cases should be treated as (extensionID, message)
+ extension1.sendMessage(ID2, "message");
+ await checkRemoteMessage("message");
+
+ extension1.sendMessage(ID2, { msg: "message" });
+ await checkRemoteMessage({ msg: "message" });
+
+ // And these should be (message, options)
+ extension1.sendMessage("message", {});
+ await checkLocalMessage("message");
+
+ // or (message, non-callback), pick your poison
+ extension1.sendMessage("message", undefined);
+ await checkLocalMessage("message");
+
+ // With three arguments, we send a cross-extension message
+ extension1.sendMessage(ID2, "message", {});
+ await checkRemoteMessage("message");
+
+ // Even when the last one is null or undefined
+ extension1.sendMessage(ID2, "message", undefined);
+ await checkRemoteMessage("message");
+
+ // The four params case is unambigous, so we allow null as a (non-) callback
+ extension1.sendMessage(ID2, "message", {}, null);
+ await checkRemoteMessage("message");
+
+ await Promise.all([extension1.unload(), extension2.unload()]);
+});
+
+add_task(async function test_sendMessage_to_badid() {
+ const extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage("badid@test-extension", "fake-message"),
+ /Could not establish connection. Receiving end does not exist./,
+ "Got the expected error message on sendMessage to badid ext"
+ );
+ browser.test.sendMessage("test-done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("test-done");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js
new file mode 100644
index 0000000000..d78197f9e4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js
@@ -0,0 +1,66 @@
+/* -*- 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_sendMessage_error() {
+ async function background() {
+ let circ = {};
+ circ.circ = circ;
+ let testCases = [
+ // [arguments, expected error string],
+ [[], "runtime.sendMessage's message argument is missing"],
+ [
+ [null, null, null, 42],
+ "runtime.sendMessage's last argument is not a function",
+ ],
+ [[null, null, 1], "runtime.sendMessage's options argument is invalid"],
+ [
+ [1, null, null],
+ "runtime.sendMessage's extensionId argument is invalid",
+ ],
+ [
+ [null, null, null, null, null],
+ "runtime.sendMessage received too many arguments",
+ ],
+
+ // Even when the parameters are accepted, we still expect an error
+ // because there is no onMessage listener.
+ [
+ [null, null, null],
+ "Could not establish connection. Receiving end does not exist.",
+ ],
+
+ // Structured cloning doesn't work with DOM objects
+ [[null, location, null], "Location object could not be cloned."],
+ [[null, [circ, location], null], "Location object could not be cloned."],
+ ];
+
+ // Repeat all tests with the undefined value instead of null.
+ for (let [args, expectedError] of testCases.slice()) {
+ args = args.map(arg => (arg === null ? undefined : arg));
+ testCases.push([args, expectedError]);
+ }
+
+ for (let [args, expectedError] of testCases) {
+ let description = `runtime.sendMessage(${args.map(String).join(", ")})`;
+
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage(...args),
+ expectedError,
+ `expected error message for ${description}`
+ );
+ }
+
+ browser.test.notifyPass("sendMessage parameter validation");
+ }
+ let extensionData = {
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitFinish("sendMessage parameter validation");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js
new file mode 100644
index 0000000000..9827a329e3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js
@@ -0,0 +1,67 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// Regression test for bug 1655624: When there are multiple onMessage receivers
+// that both handle the response asynchronously, destroying the context of one
+// recipient should not prevent the other recipient from sending a reply.
+add_task(async function onMessage_ignores_destroyed_contexts() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.onMessage.addListener(async msg => {
+ if (msg !== "startTest") {
+ return;
+ }
+ try {
+ let res = await browser.runtime.sendMessage("msg_from_bg");
+ browser.test.assertEq(0, res, "Result from onMessage");
+ browser.test.notifyPass("handled_onMessage");
+ } catch (e) {
+ browser.test.fail(`Unexpected error: ${e.message} :: ${e.stack}`);
+ browser.test.notifyFail("handled_onMessage");
+ }
+ });
+ },
+ files: {
+ "tab.html": `
+ <!DOCTYPE html><meta charset="utf-8">
+ <script src="tab.js"></script>
+ `,
+ "tab.js": () => {
+ let where = location.search.slice(1);
+ let resolveOnMessage;
+ browser.runtime.onMessage.addListener(async msg => {
+ browser.test.assertEq("msg_from_bg", msg, `onMessage at ${where}`);
+ browser.test.sendMessage(`received:${where}`);
+ return new Promise(resolve => {
+ resolveOnMessage = resolve;
+ });
+ });
+ browser.test.onMessage.addListener(msg => {
+ if (msg === `resolveOnMessage:${where}`) {
+ resolveOnMessage(0);
+ }
+ });
+ },
+ },
+ });
+ await extension.startup();
+ let tabToCloseEarly = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/tab.html?tabToCloseEarly`,
+ { extension }
+ );
+ let tabToRespond = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/tab.html?tabToRespond`,
+ { extension }
+ );
+ extension.sendMessage("startTest");
+ await Promise.all([
+ extension.awaitMessage("received:tabToCloseEarly"),
+ extension.awaitMessage("received:tabToRespond"),
+ ]);
+ await tabToCloseEarly.close();
+ extension.sendMessage("resolveOnMessage:tabToRespond");
+ await extension.awaitFinish("handled_onMessage");
+ await tabToRespond.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js
new file mode 100644
index 0000000000..23d8b05f83
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js
@@ -0,0 +1,93 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_sendMessage_without_listener() {
+ async function background() {
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage("msg"),
+ "Could not establish connection. Receiving end does not exist.",
+ "Correct error when there are no receivers from background"
+ );
+
+ browser.test.sendMessage("sendMessage-error-bg");
+ }
+ let extensionData = {
+ background,
+ files: {
+ "page.html": `<!doctype><meta charset=utf-8><script src="page.js"></script>`,
+ async "page.js"() {
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage("msg"),
+ "Could not establish connection. Receiving end does not exist.",
+ "Correct error when there are no receivers from extension page"
+ );
+
+ browser.test.notifyPass("sendMessage-error-page");
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("sendMessage-error-bg");
+
+ let url = `moz-extension://${extension.uuid}/page.html`;
+ let page = await ExtensionTestUtils.loadContentPage(url, { extension });
+ await extension.awaitFinish("sendMessage-error-page");
+ await page.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_chrome_sendMessage_without_listener() {
+ function background() {
+ /* globals chrome */
+ browser.test.assertEq(
+ null,
+ chrome.runtime.lastError,
+ "no lastError before call"
+ );
+ let retval = chrome.runtime.sendMessage("msg");
+ browser.test.assertEq(
+ null,
+ chrome.runtime.lastError,
+ "no lastError after call"
+ );
+ browser.test.assertEq(
+ undefined,
+ retval,
+ "return value of chrome.runtime.sendMessage without callback"
+ );
+
+ let isAsyncCall = false;
+ retval = chrome.runtime.sendMessage("msg", reply => {
+ browser.test.assertEq(undefined, reply, "no reply");
+ browser.test.assertTrue(
+ isAsyncCall,
+ "chrome.runtime.sendMessage's callback must be called asynchronously"
+ );
+ browser.test.assertEq(
+ undefined,
+ retval,
+ "return value of chrome.runtime.sendMessage with callback"
+ );
+ browser.test.assertEq(
+ "Could not establish connection. Receiving end does not exist.",
+ chrome.runtime.lastError.message
+ );
+ browser.test.notifyPass("finished chrome.runtime.sendMessage");
+ });
+ isAsyncCall = true;
+ }
+ let extensionData = {
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitFinish("finished chrome.runtime.sendMessage");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js
new file mode 100644
index 0000000000..7d768b47c4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js
@@ -0,0 +1,131 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+const WIN = `<html><body>dummy page setting a same-site cookie</body></html>`;
+
+// Small red image.
+const IMG_BYTES = atob(
+ "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12" +
+ "P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="
+);
+
+server.registerPathHandler("/same_site_cookies", (request, response) => {
+ // avoid confusing cache behaviors
+ response.setHeader("Cache-Control", "no-cache", false);
+
+ if (request.queryString === "loadWin") {
+ response.write(WIN);
+ return;
+ }
+
+ // using startsWith and discard the math random
+ if (request.queryString.startsWith("loadImage")) {
+ response.setHeader(
+ "Set-Cookie",
+ "myKey=mySameSiteExtensionCookie; samesite=strict",
+ true
+ );
+ response.setHeader("Content-Type", "image/png");
+ response.write(IMG_BYTES);
+ return;
+ }
+
+ if (request.queryString === "loadXHR") {
+ let cookie = "noCookie";
+ if (request.hasHeader("Cookie")) {
+ cookie = request.getHeader("Cookie");
+ }
+ response.setHeader("Content-Type", "text/plain");
+ response.write(cookie);
+ return;
+ }
+
+ // We should never get here, but just in case return something unexpected.
+ response.write("D'oh");
+});
+
+/* Description of the test:
+ * (1) We load an image from mochi.test which sets a same site cookie
+ * (2) We have the web extension perform an XHR request to mochi.test
+ * (3) We verify the web-extension can access the same-site cookie
+ */
+
+add_task(async function test_webRequest_same_site_cookie_access() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://example.com/*"],
+ content_scripts: [
+ {
+ matches: ["http://example.com/*"],
+ run_at: "document_end",
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ background() {
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "verify-same-site-cookie-moz-extension") {
+ let xhr = new XMLHttpRequest();
+ try {
+ xhr.open(
+ "GET",
+ "http://example.com/same_site_cookies?loadXHR",
+ true
+ );
+ xhr.onload = function () {
+ browser.test.assertEq(
+ "myKey=mySameSiteExtensionCookie",
+ xhr.responseText,
+ "cookie should be accessible from moz-extension context"
+ );
+ browser.test.sendMessage("same-site-cookie-test-done");
+ };
+ xhr.onerror = function () {
+ browser.test.fail("xhr onerror");
+ browser.test.sendMessage("same-site-cookie-test-done");
+ };
+ } catch (e) {
+ browser.test.fail("xhr failure: " + e);
+ }
+ xhr.send();
+ }
+ });
+ },
+
+ files: {
+ "content_script.js": function () {
+ let myImage = document.createElement("img");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ myImage.wrappedJSObject.setAttribute(
+ "src",
+ "http://example.com/same_site_cookies?loadImage" + Math.random()
+ );
+ myImage.onload = function () {
+ browser.test.log("image onload");
+ browser.test.sendMessage("image-loaded-and-same-site-cookie-set");
+ };
+ myImage.onerror = function () {
+ browser.test.log("image onerror");
+ };
+ document.body.appendChild(myImage);
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/same_site_cookies?loadWin"
+ );
+
+ await extension.awaitMessage("image-loaded-and-same-site-cookie-set");
+
+ extension.sendMessage("verify-same-site-cookie-moz-extension");
+ await extension.awaitMessage("same-site-cookie-test-done");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js
new file mode 100644
index 0000000000..df77f8b0dd
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js
@@ -0,0 +1,233 @@
+"use strict";
+
+/**
+ * This test tests various redirection scenarios, and checks whether sameSite
+ * cookies are sent.
+ *
+ * The file has the following tests:
+ * - verify_firstparty_web_behavior - base case, confirms normal web behavior.
+ * - samesite_is_foreign_without_host_permissions
+ * - wildcard_host_permissions_enable_samesite_cookies
+ * - explicit_host_permissions_enable_samesite_cookies
+ * - some_host_permissions_enable_some_samesite_cookies
+ */
+
+// This simulates a common pattern used for sites that require authentication.
+// After logging in, there may be multiple redirects, HTTP and scripted.
+const SITE_START = "start.example.net";
+// set "start" cookies + 302 redirects to found.
+const SITE_FOUND = "found.example.net";
+// set "found" cookies + uses a HTML redirect to redir.
+const SITE_REDIR = "redir.example.net";
+// set "redir" cookies + 302 redirects to final.
+const SITE_FINAL = "final.example.net";
+
+const SITE = "example.net";
+
+const URL_START = `http://${SITE_START}/start`;
+
+const server = createHttpServer({
+ hosts: [SITE_START, SITE_FOUND, SITE_REDIR, SITE_FINAL],
+});
+
+function getCookies(request) {
+ return request.hasHeader("Cookie") ? request.getHeader("Cookie") : "";
+}
+
+function sendCookies(response, prefix, suffix = "") {
+ const cookies = [
+ prefix + "-none=1; sameSite=none; domain=" + SITE + suffix,
+ prefix + "-lax=1; sameSite=lax; domain=" + SITE + suffix,
+ prefix + "-strict=1; sameSite=strict; domain=" + SITE + suffix,
+ ];
+ for (let cookie of cookies) {
+ response.setHeader("Set-Cookie", cookie, true);
+ }
+}
+
+function deleteCookies(response, prefix) {
+ sendCookies(response, prefix, "; expires=Thu, 01 Jan 1970 00:00:00 GMT");
+}
+
+var receivedCookies = [];
+
+server.registerPathHandler("/start", (request, response) => {
+ Assert.equal(request.host, SITE_START);
+ Assert.equal(getCookies(request), "", "No cookies at start of test");
+
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ sendCookies(response, "start");
+ response.setHeader("Location", `http://${SITE_FOUND}/found`);
+});
+
+server.registerPathHandler("/found", (request, response) => {
+ Assert.equal(request.host, SITE_FOUND);
+ receivedCookies.push(getCookies(request));
+
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ deleteCookies(response, "start");
+ sendCookies(response, "found");
+ response.write(`<script>location = "http://${SITE_REDIR}/redir";</script>`);
+});
+
+server.registerPathHandler("/redir", (request, response) => {
+ Assert.equal(request.host, SITE_REDIR);
+ receivedCookies.push(getCookies(request));
+
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ deleteCookies(response, "found");
+ sendCookies(response, "redir");
+ response.setHeader("Location", `http://${SITE_FINAL}/final`);
+});
+
+server.registerPathHandler("/final", (request, response) => {
+ Assert.equal(request.host, SITE_FINAL);
+ receivedCookies.push(getCookies(request));
+
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ deleteCookies(response, "redir");
+ // In test some_host_permissions_enable_some_samesite_cookies, the cookies
+ // from the start haven't been cleared due to the lack of host permissions.
+ // Do that here instead.
+ deleteCookies(response, "start");
+ response.setHeader("Location", "/final_and_clean");
+});
+
+// Should be called before any request is made.
+function promiseFinalResponse() {
+ Assert.deepEqual(receivedCookies, [], "Test starts without observed cookies");
+ return new Promise(resolve => {
+ server.registerPathHandler("/final_and_clean", (request, response) => {
+ Assert.equal(request.host, SITE_FINAL);
+ Assert.equal(getCookies(request), "", "Cookies cleaned up");
+ resolve(receivedCookies.splice(0));
+ });
+ });
+}
+
+// Load the page as a child frame of an extension, for the given permissions.
+async function getCookiesForLoadInExtension({ permissions }) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions,
+ },
+ files: {
+ "embedder.html": `<iframe src="${URL_START}"></iframe>`,
+ },
+ });
+ await extension.startup();
+ let cookiesPromise = promiseFinalResponse();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/embedder.html`,
+ { extension }
+ );
+ let cookies = await cookiesPromise;
+ await contentPage.close();
+ await extension.unload();
+ return cookies;
+}
+
+add_task(async function setup() {
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", 0);
+ Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", true);
+
+ // Test server runs on http, so disable Secure requirement of sameSite=none.
+ Services.prefs.setBoolPref(
+ "network.cookie.sameSite.noneRequiresSecure",
+ false
+ );
+});
+
+// First verify that our expectations match with the actual behavior on the web.
+add_task(async function verify_firstparty_web_behavior() {
+ let cookiesPromise = promiseFinalResponse();
+ let contentPage = await ExtensionTestUtils.loadContentPage(URL_START);
+ let cookies = await cookiesPromise;
+ await contentPage.close();
+ Assert.deepEqual(
+ cookies,
+ // Same expectations as in host_permissions_enable_samesite_cookies
+ [
+ "start-none=1; start-lax=1; start-strict=1",
+ "found-none=1; found-lax=1; found-strict=1",
+ "redir-none=1; redir-lax=1; redir-strict=1",
+ ],
+ "Expected cookies from a first-party load on the web"
+ );
+});
+
+// Verify that an extension without permission behaves like a third-party page.
+add_task(async function samesite_is_foreign_without_host_permissions() {
+ let cookies = await getCookiesForLoadInExtension({
+ permissions: [],
+ });
+
+ Assert.deepEqual(
+ cookies,
+ ["start-none=1", "found-none=1", "redir-none=1"],
+ "SameSite cookies excluded without permissions"
+ );
+});
+
+// When an extension has permissions for the site, cookies should be included.
+add_task(async function wildcard_host_permissions_enable_samesite_cookies() {
+ let cookies = await getCookiesForLoadInExtension({
+ permissions: ["*://*.example.net/*"], // = *.SITE
+ });
+
+ Assert.deepEqual(
+ cookies,
+ // Same expectations as in verify_firstparty_web_behavior.
+ [
+ "start-none=1; start-lax=1; start-strict=1",
+ "found-none=1; found-lax=1; found-strict=1",
+ "redir-none=1; redir-lax=1; redir-strict=1",
+ ],
+ "Expected cookies from a load in an extension frame"
+ );
+});
+
+// When an extension has permissions for the site, cookies should be included.
+add_task(async function explicit_host_permissions_enable_samesite_cookies() {
+ let cookies = await getCookiesForLoadInExtension({
+ permissions: [
+ "*://start.example.net/*",
+ "*://found.example.net/*",
+ "*://redir.example.net/*",
+ "*://final.example.net/*",
+ ],
+ });
+
+ Assert.deepEqual(
+ cookies,
+ // Same expectations as in verify_firstparty_web_behavior.
+ [
+ "start-none=1; start-lax=1; start-strict=1",
+ "found-none=1; found-lax=1; found-strict=1",
+ "redir-none=1; redir-lax=1; redir-strict=1",
+ ],
+ "Expected cookies from a load in an extension frame"
+ );
+});
+
+// When an extension does not have host permissions for all sites, but only
+// some, then same-site cookies are only included in requests with the right
+// permissions.
+add_task(async function some_host_permissions_enable_some_samesite_cookies() {
+ let cookies = await getCookiesForLoadInExtension({
+ permissions: ["*://start.example.net/*", "*://final.example.net/*"],
+ });
+
+ Assert.deepEqual(
+ cookies,
+ [
+ // Missing permission for "found.example.net":
+ "start-none=1",
+ // Missing permission for "redir.example.net":
+ "found-none=1",
+ // "final.example.net" can see cookies from "start.example.net":
+ "start-lax=1; start-strict=1; redir-none=1",
+ ],
+ "Expected some cookies from a load in an extension frame"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js b/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js
new file mode 100644
index 0000000000..0a8a5acdef
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js
@@ -0,0 +1,42 @@
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+function contentScript() {
+ window.x = 12;
+ browser.test.assertEq(window.x, 12, "x is 12");
+ browser.test.notifyPass("background test passed");
+}
+
+let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+};
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.awaitFinish();
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_sandboxed_resource.js b/toolkit/components/extensions/test/xpcshell/test_ext_sandboxed_resource.js
new file mode 100644
index 0000000000..05489d753d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_sandboxed_resource.js
@@ -0,0 +1,55 @@
+"use strict";
+
+// Test that an extension page which is sandboxed may load resources
+// from itself without relying on web acessible resources.
+add_task(async function test_webext_background_sandbox_privileges() {
+ function backgroundSubframeScript() {
+ window.parent.postMessage(typeof browser, "*");
+ }
+
+ function backgroundScript() {
+ /* eslint-disable-next-line mozilla/balanced-listeners */
+ window.addEventListener("message", event => {
+ if (event.data == "undefined") {
+ browser.test.notifyPass("webext-background-sandbox-privileges");
+ } else {
+ browser.test.notifyFail("webext-background-sandbox-privileges");
+ }
+ });
+ }
+
+ let extensionData = {
+ manifest: {
+ background: {
+ page: "background.html",
+ },
+ },
+ files: {
+ "background.html": `<!DOCTYPE>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <script src="background.js"><\/script>
+ <iframe src="background-subframe.html" sandbox="allow-scripts"></iframe>
+ </body>
+ </html>`,
+ "background-subframe.html": `<!DOCTYPE>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="background-subframe.js"><\/script>
+ </head>
+ </html>`,
+ "background-subframe.js": backgroundSubframeScript,
+ "background.js": backgroundScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ await extension.awaitFinish("webext-background-sandbox-privileges");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schema.js b/toolkit/components/extensions/test/xpcshell/test_ext_schema.js
new file mode 100644
index 0000000000..913aa4f9ab
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schema.js
@@ -0,0 +1,80 @@
+"use strict";
+
+AddonTestUtils.init(this);
+
+add_task(async function testEmptySchema() {
+ function background() {
+ browser.test.assertEq(
+ undefined,
+ browser.manifest,
+ "browser.manifest is not defined"
+ );
+ browser.test.assertTrue(
+ !!browser.storage,
+ "browser.storage should be defined"
+ );
+ browser.test.assertEq(
+ undefined,
+ browser.contextMenus,
+ "browser.contextMenus should not be defined"
+ );
+ browser.test.notifyPass("schema");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["storage"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("schema");
+ await extension.unload();
+});
+
+add_task(async function test_warnings_as_errors() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { unrecognized_property_that_should_be_treated_as_a_warning: 1 },
+ });
+
+ // Tests should be run with extensions.webextensions.warnings-as-errors=true
+ // by default, and prevent extensions with manifest warnings from loading.
+ await Assert.rejects(
+ extension.startup(),
+ /unrecognized_property_that_should_be_treated_as_a_warning/,
+ "extension with invalid manifest should not load if warnings-as-errors=true"
+ );
+ // When ExtensionTestUtils.failOnSchemaWarnings(false) is called, startup is
+ // expected to succeed, as shown by the next "testUnknownProperties" test.
+});
+
+add_task(async function testUnknownProperties() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["unknownPermission"],
+
+ unknown_property: {},
+ },
+
+ background() {},
+ });
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ { message: /processing permissions\.0: Value "unknownPermission"/ },
+ {
+ message:
+ /processing unknown_property: An unexpected property was found in the WebExtension manifest/,
+ },
+ ],
+ });
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
new file mode 100644
index 0000000000..a89ddf0728
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
@@ -0,0 +1,2118 @@
+"use strict";
+
+const global = this;
+
+let json = [
+ {
+ namespace: "testing",
+
+ properties: {
+ PROP1: { value: 20 },
+ prop2: { type: "string" },
+ prop3: {
+ $ref: "submodule",
+ },
+ prop4: {
+ $ref: "submodule",
+ unsupported: true,
+ },
+ },
+
+ types: [
+ {
+ id: "type1",
+ type: "string",
+ enum: ["value1", "value2", "value3"],
+ },
+
+ {
+ id: "type2",
+ type: "object",
+ properties: {
+ prop1: { type: "integer" },
+ prop2: { type: "array", items: { $ref: "type1" } },
+ },
+ },
+
+ {
+ id: "basetype1",
+ type: "object",
+ properties: {
+ prop1: { type: "string" },
+ },
+ },
+
+ {
+ id: "basetype2",
+ choices: [{ type: "integer" }],
+ },
+
+ {
+ $extend: "basetype1",
+ properties: {
+ prop2: { type: "string" },
+ },
+ },
+
+ {
+ $extend: "basetype2",
+ choices: [{ type: "string" }],
+ },
+
+ {
+ id: "basetype3",
+ type: "object",
+ properties: {
+ baseprop: { type: "string" },
+ },
+ },
+
+ {
+ id: "derivedtype1",
+ type: "object",
+ $import: "basetype3",
+ properties: {
+ derivedprop: { type: "string" },
+ },
+ },
+
+ {
+ id: "derivedtype2",
+ type: "object",
+ $import: "basetype3",
+ properties: {
+ derivedprop: { type: "integer" },
+ },
+ },
+
+ {
+ id: "submodule",
+ type: "object",
+ functions: [
+ {
+ name: "sub_foo",
+ type: "function",
+ parameters: [],
+ returns: { type: "integer" },
+ },
+ ],
+ },
+ ],
+
+ functions: [
+ {
+ name: "foo",
+ type: "function",
+ parameters: [
+ { name: "arg1", type: "integer", optional: true, default: 99 },
+ { name: "arg2", type: "boolean", optional: true },
+ ],
+ },
+
+ {
+ name: "bar",
+ type: "function",
+ parameters: [
+ { name: "arg1", type: "integer", optional: true },
+ { name: "arg2", type: "boolean" },
+ ],
+ },
+
+ {
+ name: "baz",
+ type: "function",
+ parameters: [
+ {
+ name: "arg1",
+ type: "object",
+ properties: {
+ prop1: { type: "string" },
+ prop2: { type: "integer", optional: true },
+ prop3: { type: "integer", unsupported: true },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "qux",
+ type: "function",
+ parameters: [{ name: "arg1", $ref: "type1" }],
+ },
+
+ {
+ name: "quack",
+ type: "function",
+ parameters: [{ name: "arg1", $ref: "type2" }],
+ },
+
+ {
+ name: "quora",
+ type: "function",
+ parameters: [{ name: "arg1", type: "function" }],
+ },
+
+ {
+ name: "quileute",
+ type: "function",
+ parameters: [
+ { name: "arg1", type: "integer", optional: true },
+ { name: "arg2", type: "integer" },
+ ],
+ },
+
+ {
+ name: "queets",
+ type: "function",
+ unsupported: true,
+ parameters: [],
+ },
+
+ {
+ name: "quintuplets",
+ type: "function",
+ parameters: [
+ {
+ name: "obj",
+ type: "object",
+ properties: [],
+ additionalProperties: { type: "integer" },
+ },
+ ],
+ },
+
+ {
+ name: "quasar",
+ type: "function",
+ parameters: [
+ {
+ name: "abc",
+ type: "object",
+ properties: {
+ func: {
+ type: "function",
+ parameters: [{ name: "x", type: "integer" }],
+ },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "quosimodo",
+ type: "function",
+ parameters: [
+ {
+ name: "xyz",
+ type: "object",
+ additionalProperties: { type: "any" },
+ },
+ ],
+ },
+
+ {
+ name: "patternprop",
+ type: "function",
+ parameters: [
+ {
+ name: "obj",
+ type: "object",
+ properties: { prop1: { type: "string", pattern: "^\\d+$" } },
+ patternProperties: {
+ "(?i)^prop\\d+$": { type: "string" },
+ "^foo\\d+$": { type: "string" },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "pattern",
+ type: "function",
+ parameters: [
+ { name: "arg", type: "string", pattern: "(?i)^[0-9a-f]+$" },
+ ],
+ },
+
+ {
+ name: "format",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ properties: {
+ hostname: { type: "string", format: "hostname", optional: true },
+ canonicalDomain: {
+ type: "string",
+ format: "canonicalDomain",
+ optional: "omit-key-if-missing",
+ },
+ url: { type: "string", format: "url", optional: true },
+ origin: { type: "string", format: "origin", optional: true },
+ relativeUrl: {
+ type: "string",
+ format: "relativeUrl",
+ optional: true,
+ },
+ strictRelativeUrl: {
+ type: "string",
+ format: "strictRelativeUrl",
+ optional: true,
+ },
+ imageDataOrStrictRelativeUrl: {
+ type: "string",
+ format: "imageDataOrStrictRelativeUrl",
+ optional: true,
+ },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "formatDate",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ properties: {
+ date: { type: "string", format: "date", optional: true },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "deep",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ properties: {
+ foo: {
+ type: "object",
+ properties: {
+ bar: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ baz: {
+ type: "object",
+ properties: {
+ required: { type: "integer" },
+ optional: { type: "string", optional: true },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "errors",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ properties: {
+ warn: {
+ type: "string",
+ pattern: "^\\d+$",
+ optional: true,
+ onError: "warn",
+ },
+ ignore: {
+ type: "string",
+ pattern: "^\\d+$",
+ optional: true,
+ onError: "ignore",
+ },
+ default: {
+ type: "string",
+ pattern: "^\\d+$",
+ optional: true,
+ },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "localize",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ properties: {
+ foo: { type: "string", preprocess: "localize", optional: true },
+ bar: { type: "string", optional: true },
+ url: {
+ type: "string",
+ preprocess: "localize",
+ format: "url",
+ optional: true,
+ },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "extended1",
+ type: "function",
+ parameters: [{ name: "val", $ref: "basetype1" }],
+ },
+
+ {
+ name: "extended2",
+ type: "function",
+ parameters: [{ name: "val", $ref: "basetype2" }],
+ },
+
+ {
+ name: "callderived1",
+ type: "function",
+ parameters: [{ name: "value", $ref: "derivedtype1" }],
+ },
+
+ {
+ name: "callderived2",
+ type: "function",
+ parameters: [{ name: "value", $ref: "derivedtype2" }],
+ },
+ ],
+
+ events: [
+ {
+ name: "onFoo",
+ type: "function",
+ },
+
+ {
+ name: "onBar",
+ type: "function",
+ extraParameters: [
+ {
+ name: "filter",
+ type: "integer",
+ optional: true,
+ default: 1,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ namespace: "foreign",
+ properties: {
+ foreignRef: { $ref: "testing.submodule" },
+ },
+ },
+ {
+ namespace: "inject",
+ properties: {
+ PROP1: { value: "should inject" },
+ },
+ },
+ {
+ namespace: "do-not-inject",
+ properties: {
+ PROP1: { value: "should not inject" },
+ },
+ },
+];
+
+add_task(async function () {
+ let wrapper = getContextWrapper();
+ let url = "data:," + JSON.stringify(json);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let root = {};
+ Schemas.inject(root, wrapper);
+
+ Assert.equal(root.testing.PROP1, 20, "simple value property");
+ Assert.equal(root.testing.type1.VALUE1, "value1", "enum type");
+ Assert.equal(root.testing.type1.VALUE2, "value2", "enum type");
+
+ Assert.equal("inject" in root, true, "namespace 'inject' should be injected");
+ Assert.equal(
+ root["do-not-inject"],
+ undefined,
+ "namespace 'do-not-inject' should not be injected"
+ );
+
+ root.testing.foo(11, true);
+ wrapper.verify("call", "testing", "foo", [11, true]);
+
+ root.testing.foo(true);
+ wrapper.verify("call", "testing", "foo", [99, true]);
+
+ root.testing.foo(null, true);
+ wrapper.verify("call", "testing", "foo", [99, true]);
+
+ root.testing.foo(undefined, true);
+ wrapper.verify("call", "testing", "foo", [99, true]);
+
+ root.testing.foo(11);
+ wrapper.verify("call", "testing", "foo", [11, null]);
+
+ Assert.throws(
+ () => root.testing.bar(11),
+ /Incorrect argument types/,
+ "should throw without required arg"
+ );
+
+ Assert.throws(
+ () => root.testing.bar(11, true, 10),
+ /Incorrect argument types/,
+ "should throw with too many arguments"
+ );
+
+ root.testing.bar(true);
+ wrapper.verify("call", "testing", "bar", [null, true]);
+
+ root.testing.baz({ prop1: "hello", prop2: 22 });
+ wrapper.verify("call", "testing", "baz", [{ prop1: "hello", prop2: 22 }]);
+
+ root.testing.baz({ prop1: "hello" });
+ wrapper.verify("call", "testing", "baz", [{ prop1: "hello", prop2: null }]);
+
+ root.testing.baz({ prop1: "hello", prop2: null });
+ wrapper.verify("call", "testing", "baz", [{ prop1: "hello", prop2: null }]);
+
+ Assert.throws(
+ () => root.testing.baz({ prop2: 12 }),
+ /Property "prop1" is required/,
+ "should throw without required property"
+ );
+
+ Assert.throws(
+ () => root.testing.baz({ prop1: "hi", prop3: 12 }),
+ /Property "prop3" is unsupported by Firefox/,
+ "should throw with unsupported property"
+ );
+
+ Assert.throws(
+ () => root.testing.baz({ prop1: "hi", prop4: 12 }),
+ /Unexpected property "prop4"/,
+ "should throw with unexpected property"
+ );
+
+ Assert.throws(
+ () => root.testing.baz({ prop1: 12 }),
+ /Expected string instead of 12/,
+ "should throw with wrong type"
+ );
+
+ root.testing.qux("value2");
+ wrapper.verify("call", "testing", "qux", ["value2"]);
+
+ Assert.throws(
+ () => root.testing.qux("value4"),
+ /Invalid enumeration value "value4"/,
+ "should throw for invalid enum value"
+ );
+
+ root.testing.quack({ prop1: 12, prop2: ["value1", "value3"] });
+ wrapper.verify("call", "testing", "quack", [
+ { prop1: 12, prop2: ["value1", "value3"] },
+ ]);
+
+ Assert.throws(
+ () =>
+ root.testing.quack({ prop1: 12, prop2: ["value1", "value3", "value4"] }),
+ /Invalid enumeration value "value4"/,
+ "should throw for invalid array type"
+ );
+
+ function f() {}
+ root.testing.quora(f);
+ Assert.equal(
+ JSON.stringify(wrapper.tallied.slice(0, -1)),
+ JSON.stringify(["call", "testing", "quora"])
+ );
+ Assert.equal(wrapper.tallied[3][0], f);
+ wrapper.tallied = null;
+
+ let g = () => 0;
+ root.testing.quora(g);
+ Assert.equal(
+ JSON.stringify(wrapper.tallied.slice(0, -1)),
+ JSON.stringify(["call", "testing", "quora"])
+ );
+ Assert.equal(wrapper.tallied[3][0], g);
+ wrapper.tallied = null;
+
+ root.testing.quileute(10);
+ wrapper.verify("call", "testing", "quileute", [null, 10]);
+
+ Assert.throws(
+ () => root.testing.queets(),
+ /queets is not a function/,
+ "should throw for unsupported functions"
+ );
+
+ root.testing.quintuplets({ a: 10, b: 20, c: 30 });
+ wrapper.verify("call", "testing", "quintuplets", [{ a: 10, b: 20, c: 30 }]);
+
+ Assert.throws(
+ () => root.testing.quintuplets({ a: 10, b: 20, c: 30, d: "hi" }),
+ /Expected integer instead of "hi"/,
+ "should throw for wrong additionalProperties type"
+ );
+
+ root.testing.quasar({ func: f });
+ Assert.equal(
+ JSON.stringify(wrapper.tallied.slice(0, -1)),
+ JSON.stringify(["call", "testing", "quasar"])
+ );
+ Assert.equal(wrapper.tallied[3][0].func, f);
+
+ root.testing.quosimodo({ a: 10, b: 20, c: 30 });
+ wrapper.verify("call", "testing", "quosimodo", [{ a: 10, b: 20, c: 30 }]);
+
+ Assert.throws(
+ () => root.testing.quosimodo(10),
+ /Incorrect argument types/,
+ "should throw for wrong type"
+ );
+
+ root.testing.patternprop({
+ prop1: "12",
+ prop2: "42",
+ Prop3: "43",
+ foo1: "x",
+ });
+ wrapper.verify("call", "testing", "patternprop", [
+ { prop1: "12", prop2: "42", Prop3: "43", foo1: "x" },
+ ]);
+
+ root.testing.patternprop({ prop1: "12" });
+ wrapper.verify("call", "testing", "patternprop", [{ prop1: "12" }]);
+
+ Assert.throws(
+ () => root.testing.patternprop({ prop1: "12", foo1: null }),
+ /Expected string instead of null/,
+ "should throw for wrong property type"
+ );
+
+ Assert.throws(
+ () => root.testing.patternprop({ prop1: "xx", prop2: "yy" }),
+ /String "xx" must match \/\^\\d\+\$\//,
+ "should throw for wrong property type"
+ );
+
+ Assert.throws(
+ () => root.testing.patternprop({ prop1: "12", prop2: 42 }),
+ /Expected string instead of 42/,
+ "should throw for wrong property type"
+ );
+
+ Assert.throws(
+ () => root.testing.patternprop({ prop1: "12", prop2: null }),
+ /Expected string instead of null/,
+ "should throw for wrong property type"
+ );
+
+ Assert.throws(
+ () => root.testing.patternprop({ prop1: "12", propx: "42" }),
+ /Unexpected property "propx"/,
+ "should throw for unexpected property"
+ );
+
+ Assert.throws(
+ () => root.testing.patternprop({ prop1: "12", Foo1: "x" }),
+ /Unexpected property "Foo1"/,
+ "should throw for unexpected property"
+ );
+
+ root.testing.pattern("DEADbeef");
+ wrapper.verify("call", "testing", "pattern", ["DEADbeef"]);
+
+ Assert.throws(
+ () => root.testing.pattern("DEADcow"),
+ /String "DEADcow" must match \/\^\[0-9a-f\]\+\$\/i/,
+ "should throw for non-match"
+ );
+
+ root.testing.format({ hostname: "foo" });
+ wrapper.verify("call", "testing", "format", [
+ {
+ hostname: "foo",
+ imageDataOrStrictRelativeUrl: null,
+ origin: null,
+ relativeUrl: null,
+ strictRelativeUrl: null,
+ url: null,
+ },
+ ]);
+
+ for (let invalid of ["", " ", "http://foo", "foo/bar", "foo.com/", "foo?"]) {
+ Assert.throws(
+ () => root.testing.format({ hostname: invalid }),
+ /Invalid hostname/,
+ "should throw for invalid hostname"
+ );
+ Assert.throws(
+ () => root.testing.format({ canonicalDomain: invalid }),
+ /Invalid domain /,
+ `should throw for invalid canonicalDomain (${invalid})`
+ );
+ }
+
+ for (let invalid of [
+ "%61", // ASCII should not be URL-encoded.
+ "foo:12345", // It is a common mistake to use .host instead of .hostname.
+ "2", // Single digit is an IPv4 address, but should be written as 0.0.0.2.
+ "::1", // IPv6 addresses should have brackets.
+ "[::1A]", // not lowercase.
+ "[::ffff:127.0.0.1]", // not a canonical IPv6 representation.
+ "UPPERCASE", // not lowercase.
+ "straß.de", // not punycode.
+ ]) {
+ Assert.throws(
+ () => root.testing.format({ canonicalDomain: invalid }),
+ /Invalid domain /,
+ `should throw for invalid canonicalDomain (${invalid})`
+ );
+ }
+
+ for (let valid of ["0.0.0.2", "[::1]", "[::1a]", "lowercase", "."]) {
+ root.testing.format({ canonicalDomain: valid });
+ wrapper.verify("call", "testing", "format", [
+ {
+ canonicalDomain: valid,
+ hostname: null,
+ imageDataOrStrictRelativeUrl: null,
+ origin: null,
+ relativeUrl: null,
+ strictRelativeUrl: null,
+ url: null,
+ },
+ ]);
+ }
+
+ for (let valid of [
+ "https://example.com",
+ "http://example.com",
+ "https://foo.bar.栃木.jp",
+ ]) {
+ root.testing.format({ origin: valid });
+ }
+
+ for (let invalid of [
+ "https://example.com/testing",
+ "file:/foo/bar",
+ "file:///foo/bar",
+ "",
+ " ",
+ "https://foo.bar.栃木.jp/",
+ "https://user:pass@example.com",
+ "https://*.example.com",
+ "https://example.com#test",
+ "https://example.com?test",
+ ]) {
+ Assert.throws(
+ () => root.testing.format({ origin: invalid }),
+ /Invalid origin/,
+ "should throw for invalid origin"
+ );
+ }
+
+ root.testing.format({ url: "http://foo/bar", relativeUrl: "http://foo/bar" });
+ wrapper.verify("call", "testing", "format", [
+ {
+ hostname: null,
+ imageDataOrStrictRelativeUrl: null,
+ origin: null,
+ relativeUrl: "http://foo/bar",
+ strictRelativeUrl: null,
+ url: "http://foo/bar",
+ },
+ ]);
+
+ root.testing.format({
+ relativeUrl: "foo.html",
+ strictRelativeUrl: "foo.html",
+ });
+ wrapper.verify("call", "testing", "format", [
+ {
+ hostname: null,
+ imageDataOrStrictRelativeUrl: null,
+ origin: null,
+ relativeUrl: `${wrapper.url}foo.html`,
+ strictRelativeUrl: `${wrapper.url}foo.html`,
+ url: null,
+ },
+ ]);
+
+ root.testing.format({
+ imageDataOrStrictRelativeUrl: "data:image/png;base64,A",
+ });
+ wrapper.verify("call", "testing", "format", [
+ {
+ hostname: null,
+ imageDataOrStrictRelativeUrl: "data:image/png;base64,A",
+ origin: null,
+ relativeUrl: null,
+ strictRelativeUrl: null,
+ url: null,
+ },
+ ]);
+
+ root.testing.format({
+ imageDataOrStrictRelativeUrl: "data:image/jpeg;base64,A",
+ });
+ wrapper.verify("call", "testing", "format", [
+ {
+ hostname: null,
+ imageDataOrStrictRelativeUrl: "data:image/jpeg;base64,A",
+ origin: null,
+ relativeUrl: null,
+ strictRelativeUrl: null,
+ url: null,
+ },
+ ]);
+
+ root.testing.format({ imageDataOrStrictRelativeUrl: "foo.html" });
+ wrapper.verify("call", "testing", "format", [
+ {
+ hostname: null,
+ imageDataOrStrictRelativeUrl: `${wrapper.url}foo.html`,
+ origin: null,
+ relativeUrl: null,
+ strictRelativeUrl: null,
+ url: null,
+ },
+ ]);
+
+ for (let format of ["url", "relativeUrl"]) {
+ Assert.throws(
+ () => root.testing.format({ [format]: "chrome://foo/content/" }),
+ /Access denied/,
+ "should throw for access denied"
+ );
+ }
+
+ for (let urlString of ["//foo.html", "http://foo/bar.html"]) {
+ Assert.throws(
+ () => root.testing.format({ strictRelativeUrl: urlString }),
+ /must be a relative URL/,
+ "should throw for non-relative URL"
+ );
+ }
+
+ Assert.throws(
+ () =>
+ root.testing.format({
+ imageDataOrStrictRelativeUrl: "data:image/svg+xml;utf8,A",
+ }),
+ /must be a relative or PNG or JPG data:image URL/,
+ "should throw for non-relative or non PNG/JPG data URL"
+ );
+
+ const dates = [
+ "2016-03-04",
+ "2016-03-04T08:00:00Z",
+ "2016-03-04T08:00:00.000Z",
+ "2016-03-04T08:00:00-08:00",
+ "2016-03-04T08:00:00.000-08:00",
+ "2016-03-04T08:00:00+08:00",
+ "2016-03-04T08:00:00.000+08:00",
+ "2016-03-04T08:00:00+0800",
+ "2016-03-04T08:00:00-0800",
+ ];
+ dates.forEach(str => {
+ root.testing.formatDate({ date: str });
+ wrapper.verify("call", "testing", "formatDate", [{ date: str }]);
+ });
+
+ // Make sure that a trivial change to a valid date invalidates it.
+ dates.forEach(str => {
+ Assert.throws(
+ () => root.testing.formatDate({ date: "0" + str }),
+ /Invalid date string/,
+ "should throw for invalid iso date string"
+ );
+ Assert.throws(
+ () => root.testing.formatDate({ date: str + "0" }),
+ /Invalid date string/,
+ "should throw for invalid iso date string"
+ );
+ });
+
+ const badDates = [
+ "I do not look anything like a date string",
+ "2016-99-99",
+ "2016-03-04T25:00:00Z",
+ ];
+ badDates.forEach(str => {
+ Assert.throws(
+ () => root.testing.formatDate({ date: str }),
+ /Invalid date string/,
+ "should throw for invalid iso date string"
+ );
+ });
+
+ root.testing.deep({
+ foo: { bar: [{ baz: { required: 12, optional: "42" } }] },
+ });
+ wrapper.verify("call", "testing", "deep", [
+ { foo: { bar: [{ baz: { optional: "42", required: 12 } }] } },
+ ]);
+
+ Assert.throws(
+ () => root.testing.deep({ foo: { bar: [{ baz: { optional: "42" } }] } }),
+ /Type error for parameter arg \(Error processing foo\.bar\.0\.baz: Property "required" is required\) for testing\.deep/,
+ "should throw with the correct object path"
+ );
+
+ Assert.throws(
+ () =>
+ root.testing.deep({
+ foo: { bar: [{ baz: { optional: 42, required: 12 } }] },
+ }),
+ /Type error for parameter arg \(Error processing foo\.bar\.0\.baz\.optional: Expected string instead of 42\) for testing\.deep/,
+ "should throw with the correct object path"
+ );
+
+ wrapper.talliedErrors.length = 0;
+
+ root.testing.errors({ default: "0123", ignore: "0123", warn: "0123" });
+ wrapper.verify("call", "testing", "errors", [
+ { default: "0123", ignore: "0123", warn: "0123" },
+ ]);
+ wrapper.checkErrors([]);
+
+ root.testing.errors({ default: "0123", ignore: "x123", warn: "0123" });
+ wrapper.verify("call", "testing", "errors", [
+ { default: "0123", ignore: null, warn: "0123" },
+ ]);
+ wrapper.checkErrors([]);
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ root.testing.errors({ default: "0123", ignore: "0123", warn: "x123" });
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ wrapper.verify("call", "testing", "errors", [
+ { default: "0123", ignore: "0123", warn: null },
+ ]);
+ wrapper.checkErrors(['String "x123" must match /^\\d+$/']);
+
+ root.testing.onFoo.addListener(f);
+ Assert.equal(
+ JSON.stringify(wrapper.tallied.slice(0, -1)),
+ JSON.stringify(["addListener", "testing", "onFoo"])
+ );
+ Assert.equal(wrapper.tallied[3][0], f);
+ Assert.equal(JSON.stringify(wrapper.tallied[3][1]), JSON.stringify([]));
+ wrapper.tallied = null;
+
+ root.testing.onFoo.removeListener(f);
+ Assert.equal(
+ JSON.stringify(wrapper.tallied.slice(0, -1)),
+ JSON.stringify(["removeListener", "testing", "onFoo"])
+ );
+ Assert.equal(wrapper.tallied[3][0], f);
+ wrapper.tallied = null;
+
+ root.testing.onFoo.hasListener(f);
+ Assert.equal(
+ JSON.stringify(wrapper.tallied.slice(0, -1)),
+ JSON.stringify(["hasListener", "testing", "onFoo"])
+ );
+ Assert.equal(wrapper.tallied[3][0], f);
+ wrapper.tallied = null;
+
+ Assert.throws(
+ () => root.testing.onFoo.addListener(10),
+ /Invalid listener/,
+ "addListener with non-function should throw"
+ );
+
+ root.testing.onBar.addListener(f, 10);
+ Assert.equal(
+ JSON.stringify(wrapper.tallied.slice(0, -1)),
+ JSON.stringify(["addListener", "testing", "onBar"])
+ );
+ Assert.equal(wrapper.tallied[3][0], f);
+ Assert.equal(JSON.stringify(wrapper.tallied[3][1]), JSON.stringify([10]));
+ wrapper.tallied = null;
+
+ root.testing.onBar.addListener(f);
+ Assert.equal(
+ JSON.stringify(wrapper.tallied.slice(0, -1)),
+ JSON.stringify(["addListener", "testing", "onBar"])
+ );
+ Assert.equal(wrapper.tallied[3][0], f);
+ Assert.equal(JSON.stringify(wrapper.tallied[3][1]), JSON.stringify([1]));
+ wrapper.tallied = null;
+
+ Assert.throws(
+ () => root.testing.onBar.addListener(f, "hi"),
+ /Incorrect argument types/,
+ "addListener with wrong extra parameter should throw"
+ );
+
+ let target = { prop1: 12, prop2: ["value1", "value3"] };
+ let proxy = new Proxy(target, {});
+ Assert.throws(
+ () => root.testing.quack(proxy),
+ /Expected a plain JavaScript object, got a Proxy/,
+ "should throw when passing a Proxy"
+ );
+
+ if (Symbol.toStringTag) {
+ let stringTarget = { prop1: 12, prop2: ["value1", "value3"] };
+ stringTarget[Symbol.toStringTag] = () => "[object Object]";
+ let stringProxy = new Proxy(stringTarget, {});
+ Assert.throws(
+ () => root.testing.quack(stringProxy),
+ /Expected a plain JavaScript object, got a Proxy/,
+ "should throw when passing a Proxy"
+ );
+ }
+
+ root.testing.localize({
+ foo: "__MSG_foo__",
+ bar: "__MSG_foo__",
+ url: "__MSG_http://example.com/__",
+ });
+ wrapper.verify("call", "testing", "localize", [
+ { bar: "__MSG_foo__", foo: "FOO", url: "http://example.com/" },
+ ]);
+
+ Assert.throws(
+ () => root.testing.localize({ url: "__MSG_/foo/bar__" }),
+ /\/FOO\/BAR is not a valid URL\./,
+ "should throw for invalid URL"
+ );
+
+ root.testing.extended1({ prop1: "foo", prop2: "bar" });
+ wrapper.verify("call", "testing", "extended1", [
+ { prop1: "foo", prop2: "bar" },
+ ]);
+
+ Assert.throws(
+ () => root.testing.extended1({ prop1: "foo", prop2: 12 }),
+ /Expected string instead of 12/,
+ "should throw for wrong property type"
+ );
+
+ Assert.throws(
+ () => root.testing.extended1({ prop1: "foo" }),
+ /Property "prop2" is required/,
+ "should throw for missing property"
+ );
+
+ Assert.throws(
+ () => root.testing.extended1({ prop1: "foo", prop2: "bar", prop3: "xxx" }),
+ /Unexpected property "prop3"/,
+ "should throw for extra property"
+ );
+
+ root.testing.extended2("foo");
+ wrapper.verify("call", "testing", "extended2", ["foo"]);
+
+ root.testing.extended2(12);
+ wrapper.verify("call", "testing", "extended2", [12]);
+
+ Assert.throws(
+ () => root.testing.extended2(true),
+ /Incorrect argument types/,
+ "should throw for wrong argument type"
+ );
+
+ root.testing.prop3.sub_foo();
+ wrapper.verify("call", "testing.prop3", "sub_foo", []);
+
+ Assert.throws(
+ () => root.testing.prop4.sub_foo(),
+ /root.testing.prop4 is undefined/,
+ "should throw for unsupported submodule"
+ );
+
+ root.foreign.foreignRef.sub_foo();
+ wrapper.verify("call", "foreign.foreignRef", "sub_foo", []);
+
+ root.testing.callderived1({ baseprop: "s1", derivedprop: "s2" });
+ wrapper.verify("call", "testing", "callderived1", [
+ { baseprop: "s1", derivedprop: "s2" },
+ ]);
+
+ Assert.throws(
+ () => root.testing.callderived1({ baseprop: "s1", derivedprop: 42 }),
+ /Error processing derivedprop: Expected string/,
+ "Two different objects may $import the same base object"
+ );
+ Assert.throws(
+ () => root.testing.callderived1({ baseprop: "s1" }),
+ /Property "derivedprop" is required/,
+ "Object using $import has its local properites"
+ );
+ Assert.throws(
+ () => root.testing.callderived1({ derivedprop: "s2" }),
+ /Property "baseprop" is required/,
+ "Object using $import has imported properites"
+ );
+
+ root.testing.callderived2({ baseprop: "s1", derivedprop: 42 });
+ wrapper.verify("call", "testing", "callderived2", [
+ { baseprop: "s1", derivedprop: 42 },
+ ]);
+
+ Assert.throws(
+ () => root.testing.callderived2({ baseprop: "s1", derivedprop: "s2" }),
+ /Error processing derivedprop: Expected integer/,
+ "Two different objects may $import the same base object"
+ );
+ Assert.throws(
+ () => root.testing.callderived2({ baseprop: "s1" }),
+ /Property "derivedprop" is required/,
+ "Object using $import has its local properites"
+ );
+ Assert.throws(
+ () => root.testing.callderived2({ derivedprop: 42 }),
+ /Property "baseprop" is required/,
+ "Object using $import has imported properites"
+ );
+});
+
+let deprecatedJson = [
+ {
+ namespace: "deprecated",
+
+ properties: {
+ accessor: {
+ type: "string",
+ writable: true,
+ deprecated: "This is not the property you are looking for",
+ },
+ },
+
+ types: [
+ {
+ id: "Type",
+ type: "string",
+ },
+ ],
+
+ functions: [
+ {
+ name: "property",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ properties: {
+ foo: {
+ type: "string",
+ },
+ },
+ additionalProperties: {
+ type: "any",
+ deprecated: "Unknown property",
+ },
+ },
+ ],
+ },
+
+ {
+ name: "value",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ choices: [
+ {
+ type: "integer",
+ },
+ {
+ type: "string",
+ deprecated: "Please use an integer, not ${value}",
+ },
+ ],
+ },
+ ],
+ },
+
+ {
+ name: "choices",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ deprecated: "You have no choices",
+ choices: [
+ {
+ type: "integer",
+ },
+ ],
+ },
+ ],
+ },
+
+ {
+ name: "ref",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ choices: [
+ {
+ $ref: "Type",
+ deprecated: "Deprecated alias",
+ },
+ ],
+ },
+ ],
+ },
+
+ {
+ name: "method",
+ type: "function",
+ deprecated: "Do not call this method",
+ parameters: [],
+ },
+ ],
+
+ events: [
+ {
+ name: "onDeprecated",
+ type: "function",
+ deprecated: "This event does not work",
+ },
+ ],
+ },
+];
+
+add_task(async function testDeprecation() {
+ let wrapper = getContextWrapper();
+ // This whole test expects deprecation warnings.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+
+ let url = "data:," + JSON.stringify(deprecatedJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let root = {};
+ Schemas.inject(root, wrapper);
+
+ root.deprecated.property({ foo: "bar", xxx: "any", yyy: "property" });
+ wrapper.verify("call", "deprecated", "property", [
+ { foo: "bar", xxx: "any", yyy: "property" },
+ ]);
+ wrapper.checkErrors([
+ "Warning processing xxx: Unknown property",
+ "Warning processing yyy: Unknown property",
+ ]);
+
+ root.deprecated.value(12);
+ wrapper.verify("call", "deprecated", "value", [12]);
+ wrapper.checkErrors([]);
+
+ root.deprecated.value("12");
+ wrapper.verify("call", "deprecated", "value", ["12"]);
+ wrapper.checkErrors(['Please use an integer, not "12"']);
+
+ root.deprecated.choices(12);
+ wrapper.verify("call", "deprecated", "choices", [12]);
+ wrapper.checkErrors(["You have no choices"]);
+
+ root.deprecated.ref("12");
+ wrapper.verify("call", "deprecated", "ref", ["12"]);
+ wrapper.checkErrors(["Deprecated alias"]);
+
+ root.deprecated.method();
+ wrapper.verify("call", "deprecated", "method", []);
+ wrapper.checkErrors(["Do not call this method"]);
+
+ void root.deprecated.accessor;
+ wrapper.verify("get", "deprecated", "accessor", null);
+ wrapper.checkErrors(["This is not the property you are looking for"]);
+
+ root.deprecated.accessor = "x";
+ wrapper.verify("set", "deprecated", "accessor", "x");
+ wrapper.checkErrors(["This is not the property you are looking for"]);
+
+ root.deprecated.onDeprecated.addListener(() => {});
+ wrapper.checkErrors(["This event does not work"]);
+
+ root.deprecated.onDeprecated.removeListener(() => {});
+ wrapper.checkErrors(["This event does not work"]);
+
+ root.deprecated.onDeprecated.hasListener(() => {});
+ wrapper.checkErrors(["This event does not work"]);
+
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ Assert.throws(
+ () => root.deprecated.onDeprecated.hasListener(() => {}),
+ /This event does not work/,
+ "Deprecation warning with extensions.webextensions.warnings-as-errors=true"
+ );
+});
+
+let choicesJson = [
+ {
+ namespace: "choices",
+
+ types: [],
+
+ functions: [
+ {
+ name: "meh",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ choices: [
+ {
+ type: "string",
+ enum: ["foo", "bar", "baz"],
+ },
+ {
+ type: "string",
+ pattern: "florg.*meh",
+ },
+ {
+ type: "integer",
+ minimum: 12,
+ maximum: 42,
+ },
+ ],
+ },
+ ],
+ },
+
+ {
+ name: "foo",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ choices: [
+ {
+ type: "object",
+ properties: {
+ blurg: {
+ type: "string",
+ unsupported: true,
+ optional: true,
+ },
+ },
+ additionalProperties: {
+ type: "string",
+ },
+ },
+ {
+ type: "string",
+ },
+ {
+ type: "array",
+ minItems: 2,
+ maxItems: 3,
+ items: {
+ type: "integer",
+ },
+ },
+ ],
+ },
+ ],
+ },
+
+ {
+ name: "bar",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ choices: [
+ {
+ type: "object",
+ properties: {
+ baz: {
+ type: "string",
+ },
+ },
+ },
+ {
+ type: "array",
+ items: {
+ type: "integer",
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+];
+
+add_task(async function testChoices() {
+ let wrapper = getContextWrapper();
+ let url = "data:," + JSON.stringify(choicesJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let root = {};
+ Schemas.inject(root, wrapper);
+
+ Assert.throws(
+ () => root.choices.meh("frog"),
+ /Value "frog" must either: be one of \["foo", "bar", "baz"\], match the pattern \/florg\.\*meh\/, or be an integer value/
+ );
+
+ Assert.throws(
+ () => root.choices.meh(4),
+ /be a string value, or be at least 12/
+ );
+
+ Assert.throws(
+ () => root.choices.meh(43),
+ /be a string value, or be no greater than 42/
+ );
+
+ Assert.throws(
+ () => root.choices.foo([]),
+ /be an object value, be a string value, or have at least 2 items/
+ );
+
+ Assert.throws(
+ () => root.choices.foo([1, 2, 3, 4]),
+ /be an object value, be a string value, or have at most 3 items/
+ );
+
+ Assert.throws(
+ () => root.choices.foo({ foo: 12 }),
+ /.foo must be a string value, be a string value, or be an array value/
+ );
+
+ Assert.throws(
+ () => root.choices.foo({ blurg: "foo" }),
+ /not contain an unsupported "blurg" property, be a string value, or be an array value/
+ );
+
+ Assert.throws(
+ () => root.choices.bar({}),
+ /contain the required "baz" property, or be an array value/
+ );
+
+ Assert.throws(
+ () => root.choices.bar({ baz: "x", quux: "y" }),
+ /not contain an unexpected "quux" property, or be an array value/
+ );
+
+ Assert.throws(
+ () => root.choices.bar({ baz: "x", quux: "y", foo: "z" }),
+ /not contain the unexpected properties \[foo, quux\], or be an array value/
+ );
+});
+
+let permissionsJson = [
+ {
+ namespace: "noPerms",
+
+ types: [],
+
+ functions: [
+ {
+ name: "noPerms",
+ type: "function",
+ parameters: [],
+ },
+
+ {
+ name: "fooPerm",
+ type: "function",
+ permissions: ["foo"],
+ parameters: [],
+ },
+ ],
+ },
+
+ {
+ namespace: "fooPerm",
+
+ permissions: ["foo"],
+
+ types: [],
+
+ functions: [
+ {
+ name: "noPerms",
+ type: "function",
+ parameters: [],
+ },
+
+ {
+ name: "fooBarPerm",
+ type: "function",
+ permissions: ["foo.bar"],
+ parameters: [],
+ },
+ ],
+ },
+];
+
+add_task(async function testPermissions() {
+ let url = "data:," + JSON.stringify(permissionsJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let wrapper = getContextWrapper();
+
+ let root = {};
+ Schemas.inject(root, wrapper);
+
+ equal(typeof root.noPerms, "object", "noPerms namespace should exist");
+ equal(
+ typeof root.noPerms.noPerms,
+ "function",
+ "noPerms.noPerms method should exist"
+ );
+
+ equal(
+ root.noPerms.fooPerm,
+ undefined,
+ "noPerms.fooPerm should not method exist"
+ );
+
+ equal(root.fooPerm, undefined, "fooPerm namespace should not exist");
+
+ info('Add "foo" permission');
+ wrapper.permissions.add("foo");
+
+ root = {};
+ Schemas.inject(root, wrapper);
+
+ equal(typeof root.noPerms, "object", "noPerms namespace should exist");
+ equal(
+ typeof root.noPerms.noPerms,
+ "function",
+ "noPerms.noPerms method should exist"
+ );
+ equal(
+ typeof root.noPerms.fooPerm,
+ "function",
+ "noPerms.fooPerm method should exist"
+ );
+
+ equal(typeof root.fooPerm, "object", "fooPerm namespace should exist");
+ equal(
+ typeof root.fooPerm.noPerms,
+ "function",
+ "noPerms.noPerms method should exist"
+ );
+
+ equal(
+ root.fooPerm.fooBarPerm,
+ undefined,
+ "fooPerm.fooBarPerm method should not exist"
+ );
+
+ info('Add "foo.bar" permission');
+ wrapper.permissions.add("foo.bar");
+
+ root = {};
+ Schemas.inject(root, wrapper);
+
+ equal(typeof root.noPerms, "object", "noPerms namespace should exist");
+ equal(
+ typeof root.noPerms.noPerms,
+ "function",
+ "noPerms.noPerms method should exist"
+ );
+ equal(
+ typeof root.noPerms.fooPerm,
+ "function",
+ "noPerms.fooPerm method should exist"
+ );
+
+ equal(typeof root.fooPerm, "object", "fooPerm namespace should exist");
+ equal(
+ typeof root.fooPerm.noPerms,
+ "function",
+ "noPerms.noPerms method should exist"
+ );
+ equal(
+ typeof root.fooPerm.fooBarPerm,
+ "function",
+ "noPerms.fooBarPerm method should exist"
+ );
+});
+
+let nestedNamespaceJson = [
+ {
+ namespace: "nested.namespace",
+ types: [
+ {
+ id: "CustomType",
+ type: "object",
+ events: [
+ {
+ name: "onEvent",
+ type: "function",
+ },
+ ],
+ properties: {
+ url: {
+ type: "string",
+ },
+ },
+ functions: [
+ {
+ name: "functionOnCustomType",
+ type: "function",
+ parameters: [
+ {
+ name: "title",
+ type: "string",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ properties: {
+ instanceOfCustomType: {
+ $ref: "CustomType",
+ },
+ },
+ functions: [
+ {
+ name: "create",
+ type: "function",
+ parameters: [
+ {
+ name: "title",
+ type: "string",
+ },
+ ],
+ },
+ ],
+ },
+];
+
+add_task(async function testNestedNamespace() {
+ let url = "data:," + JSON.stringify(nestedNamespaceJson);
+ let wrapper = getContextWrapper();
+
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let root = {};
+ Schemas.inject(root, wrapper);
+
+ ok(root.nested, "The root object contains the first namespace level");
+ ok(
+ root.nested.namespace,
+ "The first level object contains the second namespace level"
+ );
+
+ ok(
+ root.nested.namespace.create,
+ "Got the expected function in the nested namespace"
+ );
+ equal(
+ typeof root.nested.namespace.create,
+ "function",
+ "The property is a function as expected"
+ );
+
+ let { instanceOfCustomType } = root.nested.namespace;
+
+ ok(
+ instanceOfCustomType,
+ "Got the expected instance of the CustomType defined in the schema"
+ );
+ ok(
+ instanceOfCustomType.functionOnCustomType,
+ "Got the expected method in the CustomType instance"
+ );
+ ok(
+ instanceOfCustomType.onEvent &&
+ instanceOfCustomType.onEvent.addListener &&
+ typeof instanceOfCustomType.onEvent.addListener == "function",
+ "Got the expected event defined in the CustomType instance"
+ );
+
+ instanceOfCustomType.functionOnCustomType("param_value");
+ wrapper.verify(
+ "call",
+ "nested.namespace.instanceOfCustomType",
+ "functionOnCustomType",
+ ["param_value"]
+ );
+
+ let fakeListener = () => {};
+ instanceOfCustomType.onEvent.addListener(fakeListener);
+ wrapper.verify(
+ "addListener",
+ "nested.namespace.instanceOfCustomType",
+ "onEvent",
+ [fakeListener, []]
+ );
+ instanceOfCustomType.onEvent.removeListener(fakeListener);
+ wrapper.verify(
+ "removeListener",
+ "nested.namespace.instanceOfCustomType",
+ "onEvent",
+ [fakeListener]
+ );
+
+ // TODO: test support properties in a SubModuleType defined in the schema,
+ // once implemented, e.g.:
+ // ok("url" in instanceOfCustomType,
+ // "Got the expected property defined in the CustomType instance");
+});
+
+let $importJson = [
+ {
+ namespace: "from_the",
+ $import: "future",
+ },
+ {
+ namespace: "future",
+ properties: {
+ PROP1: { value: "original value" },
+ PROP2: { value: "second original" },
+ },
+ types: [
+ {
+ id: "Colour",
+ type: "string",
+ enum: ["red", "white", "blue"],
+ },
+ ],
+ functions: [
+ {
+ name: "dye",
+ type: "function",
+ parameters: [{ name: "arg", $ref: "Colour" }],
+ },
+ ],
+ },
+ {
+ namespace: "embrace",
+ $import: "future",
+ properties: {
+ PROP2: { value: "overridden value" },
+ },
+ types: [
+ {
+ id: "Colour",
+ type: "string",
+ enum: ["blue", "orange"],
+ },
+ ],
+ },
+];
+
+add_task(async function test_$import() {
+ let wrapper = getContextWrapper();
+ let url = "data:," + JSON.stringify($importJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let root = {};
+ Schemas.inject(root, wrapper);
+
+ equal(root.from_the.PROP1, "original value", "imported property");
+ equal(root.from_the.PROP2, "second original", "second imported property");
+ equal(root.from_the.Colour.RED, "red", "imported enum type");
+ equal(typeof root.from_the.dye, "function", "imported function");
+
+ root.from_the.dye("white");
+ wrapper.verify("call", "from_the", "dye", ["white"]);
+
+ Assert.throws(
+ () => root.from_the.dye("orange"),
+ /Invalid enumeration value/,
+ "original imported argument type Colour doesn't include 'orange'"
+ );
+
+ equal(root.embrace.PROP1, "original value", "imported property");
+ equal(root.embrace.PROP2, "overridden value", "overridden property");
+ equal(root.embrace.Colour.ORANGE, "orange", "overridden enum type");
+ equal(typeof root.embrace.dye, "function", "imported function");
+
+ root.embrace.dye("orange");
+ wrapper.verify("call", "embrace", "dye", ["orange"]);
+
+ Assert.throws(
+ () => root.embrace.dye("white"),
+ /Invalid enumeration value/,
+ "overridden argument type Colour doesn't include 'white'"
+ );
+});
+
+add_task(async function testLocalAPIImplementation() {
+ let countGet2 = 0;
+ let countProp3 = 0;
+ let countProp3SubFoo = 0;
+
+ let testingApiObj = {
+ get PROP1() {
+ // PROP1 is a schema-defined constant.
+ throw new Error("Unexpected get PROP1");
+ },
+ get prop2() {
+ ++countGet2;
+ return "prop2 val";
+ },
+ get prop3() {
+ throw new Error("Unexpected get prop3");
+ },
+ set prop3(v) {
+ // prop3 is a submodule, defined as a function, so the API should not pass
+ // through assignment to prop3.
+ throw new Error("Unexpected set prop3");
+ },
+ };
+ let submoduleApiObj = {
+ get sub_foo() {
+ ++countProp3;
+ return () => {
+ return ++countProp3SubFoo;
+ };
+ },
+ };
+
+ let localWrapper = {
+ manifestVersion: 2,
+ cloneScope: global,
+ shouldInject(ns, name) {
+ return name == "testing" || ns == "testing" || ns == "testing.prop3";
+ },
+ getImplementation(ns, name) {
+ Assert.ok(ns == "testing" || ns == "testing.prop3");
+ if (ns == "testing.prop3" && name == "sub_foo") {
+ // It is fine to use `null` here because we don't call async functions.
+ return new LocalAPIImplementation(submoduleApiObj, name, null);
+ }
+ // It is fine to use `null` here because we don't call async functions.
+ return new LocalAPIImplementation(testingApiObj, name, null);
+ },
+ };
+
+ let root = {};
+ Schemas.inject(root, localWrapper);
+ Assert.equal(countGet2, 0);
+ Assert.equal(countProp3, 0);
+ Assert.equal(countProp3SubFoo, 0);
+
+ Assert.equal(root.testing.PROP1, 20);
+
+ Assert.equal(root.testing.prop2, "prop2 val");
+ Assert.equal(countGet2, 1);
+
+ Assert.equal(root.testing.prop2, "prop2 val");
+ Assert.equal(countGet2, 2);
+
+ info(JSON.stringify(root.testing));
+ Assert.equal(root.testing.prop3.sub_foo(), 1);
+ Assert.equal(countProp3, 1);
+ Assert.equal(countProp3SubFoo, 1);
+
+ Assert.equal(root.testing.prop3.sub_foo(), 2);
+ Assert.equal(countProp3, 2);
+ Assert.equal(countProp3SubFoo, 2);
+
+ root.testing.prop3.sub_foo = () => {
+ return "overwritten";
+ };
+ Assert.equal(root.testing.prop3.sub_foo(), "overwritten");
+
+ root.testing.prop3 = {
+ sub_foo() {
+ return "overwritten again";
+ },
+ };
+ Assert.equal(root.testing.prop3.sub_foo(), "overwritten again");
+ Assert.equal(countProp3SubFoo, 2);
+});
+
+let defaultsJson = [
+ {
+ namespace: "defaultsJson",
+
+ types: [],
+
+ functions: [
+ {
+ name: "defaultFoo",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ optional: true,
+ properties: {
+ prop1: { type: "integer", optional: true },
+ },
+ default: { prop1: 1 },
+ },
+ ],
+ returns: {
+ type: "object",
+ additionalProperties: true,
+ },
+ },
+ ],
+ },
+];
+
+add_task(async function testDefaults() {
+ let url = "data:," + JSON.stringify(defaultsJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let testingApiObj = {
+ defaultFoo: function (arg) {
+ if (Object.keys(arg) != "prop1") {
+ throw new Error(
+ `Received the expected default object, default: ${JSON.stringify(
+ arg
+ )}`
+ );
+ }
+ arg.newProp = 1;
+ return arg;
+ },
+ };
+
+ let localWrapper = {
+ manifestVersion: 2,
+ cloneScope: global,
+ shouldInject(ns) {
+ return true;
+ },
+ getImplementation(ns, name) {
+ return new LocalAPIImplementation(testingApiObj, name, null);
+ },
+ };
+
+ let root = {};
+ Schemas.inject(root, localWrapper);
+
+ deepEqual(root.defaultsJson.defaultFoo(), { prop1: 1, newProp: 1 });
+ deepEqual(root.defaultsJson.defaultFoo({ prop1: 2 }), {
+ prop1: 2,
+ newProp: 1,
+ });
+ deepEqual(root.defaultsJson.defaultFoo(), { prop1: 1, newProp: 1 });
+});
+
+let returnsJson = [
+ {
+ namespace: "returns",
+ types: [
+ {
+ id: "Widget",
+ type: "object",
+ properties: {
+ size: { type: "integer" },
+ colour: { type: "string", optional: true },
+ },
+ },
+ ],
+ functions: [
+ {
+ name: "complete",
+ type: "function",
+ returns: { $ref: "Widget" },
+ parameters: [],
+ },
+ {
+ name: "optional",
+ type: "function",
+ returns: { $ref: "Widget" },
+ parameters: [],
+ },
+ {
+ name: "invalid",
+ type: "function",
+ returns: { $ref: "Widget" },
+ parameters: [],
+ },
+ ],
+ },
+];
+
+add_task(async function testReturns() {
+ const url = "data:," + JSON.stringify(returnsJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ const apiObject = {
+ complete() {
+ return { size: 3, colour: "orange" };
+ },
+ optional() {
+ return { size: 4 };
+ },
+ invalid() {
+ return {};
+ },
+ };
+
+ const localWrapper = {
+ manifestVersion: 2,
+ cloneScope: global,
+ shouldInject(ns) {
+ return true;
+ },
+ getImplementation(ns, name) {
+ return new LocalAPIImplementation(apiObject, name, null);
+ },
+ };
+
+ const root = {};
+ Schemas.inject(root, localWrapper);
+
+ deepEqual(root.returns.complete(), { size: 3, colour: "orange" });
+ deepEqual(
+ root.returns.optional(),
+ { size: 4 },
+ "Missing optional properties is allowed"
+ );
+
+ if (AppConstants.DEBUG) {
+ Assert.throws(
+ () => root.returns.invalid(),
+ /Type error for result value \(Property "size" is required\)/,
+ "Should throw for invalid result in DEBUG builds"
+ );
+ } else {
+ deepEqual(
+ root.returns.invalid(),
+ {},
+ "Doesn't throw for invalid result value in release builds"
+ );
+ }
+});
+
+let booleanEnumJson = [
+ {
+ namespace: "booleanEnum",
+
+ types: [
+ {
+ id: "enumTrue",
+ type: "boolean",
+ enum: [true],
+ },
+ ],
+ functions: [
+ {
+ name: "paramMustBeTrue",
+ type: "function",
+ parameters: [{ name: "arg", $ref: "enumTrue" }],
+ },
+ ],
+ },
+];
+
+add_task(async function testBooleanEnum() {
+ let wrapper = getContextWrapper();
+
+ let url = "data:," + JSON.stringify(booleanEnumJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let root = {};
+ Schemas.inject(root, wrapper);
+
+ ok(root.booleanEnum, "namespace exists");
+ root.booleanEnum.paramMustBeTrue(true);
+ wrapper.verify("call", "booleanEnum", "paramMustBeTrue", [true]);
+ Assert.throws(
+ () => root.booleanEnum.paramMustBeTrue(false),
+ /Type error for parameter arg \(Invalid value false\) for booleanEnum\.paramMustBeTrue\./,
+ "should throw because enum of the type restricts parameter to true"
+ );
+});
+
+let xoriginJson = [
+ {
+ namespace: "xorigin",
+ types: [],
+ functions: [
+ {
+ name: "foo",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "any",
+ },
+ ],
+ },
+ {
+ name: "crossFoo",
+ type: "function",
+ allowCrossOriginArguments: true,
+ parameters: [
+ {
+ name: "arg",
+ type: "any",
+ },
+ ],
+ },
+ ],
+ },
+];
+
+add_task(async function testCrossOriginArguments() {
+ let url = "data:," + JSON.stringify(xoriginJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let sandbox = new Cu.Sandbox("http://test.com");
+
+ let testingApiObj = {
+ foo(arg) {
+ sandbox.result = JSON.stringify(arg);
+ },
+ crossFoo(arg) {
+ sandbox.xResult = JSON.stringify(arg);
+ },
+ };
+
+ let localWrapper = {
+ manifestVersion: 2,
+ cloneScope: sandbox,
+ shouldInject(ns) {
+ return true;
+ },
+ getImplementation(ns, name) {
+ return new LocalAPIImplementation(testingApiObj, name, null);
+ },
+ };
+
+ let root = {};
+ Schemas.inject(root, localWrapper);
+
+ Assert.throws(
+ () => root.xorigin.foo({ key: 13 }),
+ /Permission denied to pass object/
+ );
+ equal(sandbox.result, undefined, "Foo can't read cross origin object.");
+
+ root.xorigin.crossFoo({ answer: 42 });
+ equal(sandbox.xResult, '{"answer":42}', "Can read cross origin object.");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js
new file mode 100644
index 0000000000..16743049d0
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js
@@ -0,0 +1,160 @@
+"use strict";
+
+const { Schemas } = ChromeUtils.importESModule(
+ "resource://gre/modules/Schemas.sys.mjs"
+);
+
+const global = this;
+
+let schemaJson = [
+ {
+ namespace: "noAllowedContexts",
+ properties: {
+ prop1: { type: "object" },
+ prop2: { type: "object", allowedContexts: ["test_zero", "test_one"] },
+ prop3: { type: "number", value: 1 },
+ prop4: { type: "number", value: 1, allowedContexts: ["numeric_one"] },
+ },
+ },
+ {
+ namespace: "defaultContexts",
+ defaultContexts: ["test_two"],
+ properties: {
+ prop1: { type: "object" },
+ prop2: { type: "object", allowedContexts: ["test_three"] },
+ prop3: { type: "number", value: 1 },
+ prop4: { type: "number", value: 1, allowedContexts: ["numeric_two"] },
+ },
+ },
+ {
+ namespace: "withAllowedContexts",
+ allowedContexts: ["test_four"],
+ properties: {
+ prop1: { type: "object" },
+ prop2: { type: "object", allowedContexts: ["test_five"] },
+ prop3: { type: "number", value: 1 },
+ prop4: { type: "number", value: 1, allowedContexts: ["numeric_three"] },
+ },
+ },
+ {
+ namespace: "withAllowedContextsAndDefault",
+ allowedContexts: ["test_six"],
+ defaultContexts: ["test_seven"],
+ properties: {
+ prop1: { type: "object" },
+ prop2: { type: "object", allowedContexts: ["test_eight"] },
+ prop3: { type: "number", value: 1 },
+ prop4: { type: "number", value: 1, allowedContexts: ["numeric_four"] },
+ },
+ },
+ {
+ namespace: "with_submodule",
+ defaultContexts: ["test_nine"],
+ types: [
+ {
+ id: "subtype",
+ type: "object",
+ functions: [
+ {
+ name: "noAllowedContexts",
+ type: "function",
+ parameters: [],
+ },
+ {
+ name: "allowedContexts",
+ allowedContexts: ["test_ten"],
+ type: "function",
+ parameters: [],
+ },
+ ],
+ },
+ ],
+ properties: {
+ prop1: { $ref: "subtype" },
+ prop2: { $ref: "subtype", allowedContexts: ["test_eleven"] },
+ },
+ },
+];
+
+add_task(async function testRestrictions() {
+ let url = "data:," + JSON.stringify(schemaJson);
+ await Schemas.load(url);
+ let results = {};
+ let localWrapper = {
+ manifestVersion: 2,
+ cloneScope: global,
+ shouldInject(ns, name, allowedContexts) {
+ name = ns ? ns + "." + name : name;
+ results[name] = allowedContexts.join(",");
+ return true;
+ },
+ getImplementation() {
+ // The actual implementation is not significant for this test.
+ // Let's take this opportunity to see if schema generation is free of
+ // exceptions even when somehow getImplementation does not return an
+ // implementation.
+ },
+ };
+
+ let root = {};
+ Schemas.inject(root, localWrapper);
+
+ function verify(path, expected) {
+ let obj = root;
+ for (let thing of path.split(".")) {
+ try {
+ obj = obj[thing];
+ } catch (e) {
+ // Blech.
+ }
+ }
+
+ let result = results[path];
+ equal(result, expected, path);
+ }
+
+ verify("noAllowedContexts", "");
+ verify("noAllowedContexts.prop1", "");
+ verify("noAllowedContexts.prop2", "test_zero,test_one");
+ verify("noAllowedContexts.prop3", "");
+ verify("noAllowedContexts.prop4", "numeric_one");
+
+ verify("defaultContexts", "");
+ verify("defaultContexts.prop1", "test_two");
+ verify("defaultContexts.prop2", "test_three");
+ verify("defaultContexts.prop3", "test_two");
+ verify("defaultContexts.prop4", "numeric_two");
+
+ verify("withAllowedContexts", "test_four");
+ verify("withAllowedContexts.prop1", "");
+ verify("withAllowedContexts.prop2", "test_five");
+ verify("withAllowedContexts.prop3", "");
+ verify("withAllowedContexts.prop4", "numeric_three");
+
+ verify("withAllowedContextsAndDefault", "test_six");
+ verify("withAllowedContextsAndDefault.prop1", "test_seven");
+ verify("withAllowedContextsAndDefault.prop2", "test_eight");
+ verify("withAllowedContextsAndDefault.prop3", "test_seven");
+ verify("withAllowedContextsAndDefault.prop4", "numeric_four");
+
+ verify("with_submodule", "");
+ verify("with_submodule.prop1", "test_nine");
+ verify("with_submodule.prop1.noAllowedContexts", "test_nine");
+ verify("with_submodule.prop1.allowedContexts", "test_ten");
+ verify("with_submodule.prop2", "test_eleven");
+ // Note: test_nine inherits allowed contexts from the namespace, not from
+ // submodule. There is no "defaultContexts" for submodule types to not
+ // complicate things.
+ verify("with_submodule.prop1.noAllowedContexts", "test_nine");
+ verify("with_submodule.prop1.allowedContexts", "test_ten");
+
+ // This is a constant, so it does not matter that getImplementation does not
+ // return an implementation since the API injector should take care of it.
+ equal(root.noAllowedContexts.prop3, 1);
+
+ Assert.throws(
+ () => root.noAllowedContexts.prop1,
+ /undefined/,
+ "Should throw when the implementation is absent."
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js
new file mode 100644
index 0000000000..2613593771
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js
@@ -0,0 +1,352 @@
+"use strict";
+
+const { Schemas } = ChromeUtils.importESModule(
+ "resource://gre/modules/Schemas.sys.mjs"
+);
+
+let { BaseContext, LocalAPIImplementation } = ExtensionCommon;
+
+let schemaJson = [
+ {
+ namespace: "testnamespace",
+ types: [
+ {
+ id: "Widget",
+ type: "object",
+ properties: {
+ size: { type: "integer" },
+ colour: { type: "string", optional: true },
+ },
+ },
+ ],
+ functions: [
+ {
+ name: "one_required",
+ type: "function",
+ parameters: [
+ {
+ name: "first",
+ type: "function",
+ parameters: [],
+ },
+ ],
+ },
+ {
+ name: "one_optional",
+ type: "function",
+ parameters: [
+ {
+ name: "first",
+ type: "function",
+ parameters: [],
+ optional: true,
+ },
+ ],
+ },
+ {
+ name: "async_required",
+ type: "function",
+ async: "first",
+ parameters: [
+ {
+ name: "first",
+ type: "function",
+ parameters: [],
+ },
+ ],
+ },
+ {
+ name: "async_optional",
+ type: "function",
+ async: "first",
+ parameters: [
+ {
+ name: "first",
+ type: "function",
+ parameters: [],
+ optional: true,
+ },
+ ],
+ },
+ {
+ name: "async_result",
+ type: "function",
+ async: "callback",
+ parameters: [
+ {
+ name: "callback",
+ type: "function",
+ parameters: [
+ {
+ name: "widget",
+ $ref: "Widget",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+];
+
+const global = this;
+class StubContext extends BaseContext {
+ constructor() {
+ let fakeExtension = { id: "test@web.extension" };
+ super("testEnv", fakeExtension);
+ this.sandbox = Cu.Sandbox(global);
+ }
+
+ get cloneScope() {
+ return this.sandbox;
+ }
+
+ get principal() {
+ return Cu.getObjectPrincipal(this.sandbox);
+ }
+}
+
+let context;
+
+function generateAPIs(extraWrapper, apiObj) {
+ context = new StubContext();
+ let localWrapper = {
+ manifestVersion: 2,
+ cloneScope: global,
+ shouldInject() {
+ return true;
+ },
+ getImplementation(namespace, name) {
+ return new LocalAPIImplementation(apiObj, name, context);
+ },
+ };
+ Object.assign(localWrapper, extraWrapper);
+
+ let root = {};
+ Schemas.inject(root, localWrapper);
+ return root.testnamespace;
+}
+
+add_task(async function testParameterValidation() {
+ await Schemas.load("data:," + JSON.stringify(schemaJson));
+
+ let testnamespace;
+ function assertThrows(name, ...args) {
+ Assert.throws(
+ () => testnamespace[name](...args),
+ /Incorrect argument types/,
+ `Expected testnamespace.${name}(${args.map(String).join(", ")}) to throw.`
+ );
+ }
+ function assertNoThrows(name, ...args) {
+ try {
+ testnamespace[name](...args);
+ } catch (e) {
+ info(
+ `testnamespace.${name}(${args
+ .map(String)
+ .join(", ")}) unexpectedly threw.`
+ );
+ throw new Error(e);
+ }
+ }
+ let cb = () => {};
+
+ for (let isChromeCompat of [true, false]) {
+ info(`Testing API validation with isChromeCompat=${isChromeCompat}`);
+ testnamespace = generateAPIs(
+ {
+ isChromeCompat,
+ },
+ {
+ one_required() {},
+ one_optional() {},
+ async_required() {},
+ async_optional() {},
+ }
+ );
+
+ assertThrows("one_required");
+ assertThrows("one_required", null);
+ assertNoThrows("one_required", cb);
+ assertThrows("one_required", cb, null);
+ assertThrows("one_required", cb, cb);
+
+ assertNoThrows("one_optional");
+ assertNoThrows("one_optional", null);
+ assertNoThrows("one_optional", cb);
+ assertThrows("one_optional", cb, null);
+ assertThrows("one_optional", cb, cb);
+
+ // Schema-based validation happens before an async method is called, so
+ // errors should be thrown synchronously.
+
+ // The parameter was declared as required, but there was also an "async"
+ // attribute with the same value as the parameter name, so the callback
+ // parameter is actually optional.
+ assertNoThrows("async_required");
+ assertNoThrows("async_required", null);
+ assertNoThrows("async_required", cb);
+ assertThrows("async_required", cb, null);
+ assertThrows("async_required", cb, cb);
+
+ assertNoThrows("async_optional");
+ assertNoThrows("async_optional", null);
+ assertNoThrows("async_optional", cb);
+ assertThrows("async_optional", cb, null);
+ assertThrows("async_optional", cb, cb);
+ }
+});
+
+add_task(async function testCheckAsyncResults() {
+ await Schemas.load("data:," + JSON.stringify(schemaJson));
+
+ const complete = generateAPIs(
+ {},
+ {
+ async_result: async () => ({ size: 5, colour: "green" }),
+ }
+ );
+
+ const optional = generateAPIs(
+ {},
+ {
+ async_result: async () => ({ size: 6 }),
+ }
+ );
+
+ const invalid = generateAPIs(
+ {},
+ {
+ async_result: async () => ({}),
+ }
+ );
+
+ deepEqual(await complete.async_result(), { size: 5, colour: "green" });
+
+ deepEqual(
+ await optional.async_result(),
+ { size: 6 },
+ "Missing optional properties is allowed"
+ );
+
+ if (AppConstants.DEBUG) {
+ await Assert.rejects(
+ invalid.async_result(),
+ /Type error for widget value \(Property "size" is required\)/,
+ "Should throw for invalid callback argument in DEBUG builds"
+ );
+ } else {
+ deepEqual(
+ await invalid.async_result(),
+ {},
+ "Invalid callback argument doesn't throw in release builds"
+ );
+ }
+});
+
+add_task(async function testAsyncResults() {
+ await Schemas.load("data:," + JSON.stringify(schemaJson));
+ function runWithCallback(func) {
+ info(`Calling testnamespace.${func.name}, expecting callback with result`);
+ return new Promise(resolve => {
+ let result = "uninitialized value";
+ let returnValue = func(reply => {
+ result = reply;
+ resolve(result);
+ });
+ // When a callback is given, the return value must be missing.
+ Assert.equal(returnValue, undefined);
+ // Callback must be called asynchronously.
+ Assert.equal(result, "uninitialized value");
+ });
+ }
+
+ function runFailCallback(func) {
+ info(`Calling testnamespace.${func.name}, expecting callback with error`);
+ return new Promise(resolve => {
+ func(reply => {
+ Assert.equal(reply, undefined);
+ resolve(context.lastError.message); // eslint-disable-line no-undef
+ });
+ });
+ }
+
+ for (let isChromeCompat of [true, false]) {
+ info(`Testing API invocation with isChromeCompat=${isChromeCompat}`);
+ let testnamespace = generateAPIs(
+ {
+ isChromeCompat,
+ },
+ {
+ async_required(cb) {
+ Assert.equal(cb, undefined);
+ return Promise.resolve(1);
+ },
+ async_optional(cb) {
+ Assert.equal(cb, undefined);
+ return Promise.resolve(2);
+ },
+ }
+ );
+ if (!isChromeCompat) {
+ // No promises for chrome.
+ info("testnamespace.async_required should be a Promise");
+ let promise = testnamespace.async_required();
+ Assert.ok(promise instanceof context.cloneScope.Promise);
+ Assert.equal(await promise, 1);
+
+ info("testnamespace.async_optional should be a Promise");
+ promise = testnamespace.async_optional();
+ Assert.ok(promise instanceof context.cloneScope.Promise);
+ Assert.equal(await promise, 2);
+ }
+
+ Assert.equal(await runWithCallback(testnamespace.async_required), 1);
+ Assert.equal(await runWithCallback(testnamespace.async_optional), 2);
+
+ let otherSandbox = Cu.Sandbox(null, {});
+ let errorFactories = [
+ msg => {
+ throw new context.cloneScope.Error(msg);
+ },
+ msg => context.cloneScope.Promise.reject({ message: msg }),
+ msg => Cu.evalInSandbox(`throw new Error("${msg}")`, otherSandbox),
+ msg =>
+ Cu.evalInSandbox(`Promise.reject({message: "${msg}"})`, otherSandbox),
+ ];
+ for (let makeError of errorFactories) {
+ info(`Testing callback/promise with error caused by: ${makeError}`);
+ testnamespace = generateAPIs(
+ {
+ isChromeCompat,
+ },
+ {
+ async_required() {
+ return makeError("ONE");
+ },
+ async_optional() {
+ return makeError("TWO");
+ },
+ }
+ );
+
+ if (!isChromeCompat) {
+ // No promises for chrome.
+ await Assert.rejects(
+ testnamespace.async_required(),
+ /ONE/,
+ "should reject testnamespace.async_required()"
+ );
+ await Assert.rejects(
+ testnamespace.async_optional(),
+ /TWO/,
+ "should reject testnamespace.async_optional()"
+ );
+ }
+
+ Assert.equal(await runFailCallback(testnamespace.async_required), "ONE");
+ Assert.equal(await runFailCallback(testnamespace.async_optional), "TWO");
+ }
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js
new file mode 100644
index 0000000000..986dc74bc5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js
@@ -0,0 +1,173 @@
+"use strict";
+
+const { ExtensionProcessScript } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionProcessScript.sys.mjs"
+);
+
+let experimentAPIs = {
+ userinputtest: {
+ schema: "schema.json",
+ parent: {
+ scopes: ["addon_parent"],
+ script: "parent.js",
+ paths: [["userinputtest"]],
+ },
+ child: {
+ scopes: ["addon_child"],
+ script: "child.js",
+ paths: [["userinputtest", "child"]],
+ },
+ },
+};
+
+let experimentFiles = {
+ "schema.json": JSON.stringify([
+ {
+ namespace: "userinputtest",
+ functions: [
+ {
+ name: "test",
+ type: "function",
+ async: true,
+ requireUserInput: true,
+ parameters: [],
+ },
+ {
+ name: "child",
+ type: "function",
+ async: true,
+ requireUserInput: true,
+ parameters: [],
+ },
+ ],
+ },
+ ]),
+
+ /* globals ExtensionAPI */
+ "parent.js": () => {
+ this.userinputtest = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ userinputtest: {
+ test() {},
+ },
+ };
+ }
+ };
+ },
+
+ "child.js": () => {
+ this.userinputtest = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ userinputtest: {
+ child() {},
+ },
+ };
+ }
+ };
+ },
+};
+
+// Set the "handlingUserInput" flag for the given extension's background page.
+// Returns an RAIIHelper that should be destruct()ed eventually.
+function setHandlingUserInput(extension) {
+ let extensionChild = ExtensionProcessScript.getExtensionChild(extension.id);
+ let bgwin = null;
+ for (let view of extensionChild.views) {
+ if (view.viewType == "background") {
+ bgwin = view.contentWindow;
+ break;
+ }
+ }
+ notEqual(bgwin, null, "Found background window for the test extension");
+ let winutils = bgwin.windowUtils;
+ return winutils.setHandlingUserInput(true);
+}
+
+// Test that the schema requireUserInput flag works correctly for
+// proxied api implementations.
+add_task(async function test_proxy() {
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ background() {
+ browser.test.onMessage.addListener(async () => {
+ try {
+ await browser.userinputtest.test();
+ browser.test.sendMessage("result", null);
+ } catch (err) {
+ browser.test.sendMessage("result", err.message);
+ }
+ });
+ },
+ manifest: {
+ permissions: ["experiments.userinputtest"],
+ experiment_apis: experimentAPIs,
+ },
+ files: experimentFiles,
+ });
+
+ await extension.startup();
+
+ extension.sendMessage("test");
+ let result = await extension.awaitMessage("result");
+ ok(
+ /test may only be called from a user input handler/.test(result),
+ `function failed when not called from a user input handler: ${result}`
+ );
+
+ let handle = setHandlingUserInput(extension);
+ extension.sendMessage("test");
+ result = await extension.awaitMessage("result");
+ equal(
+ result,
+ null,
+ "function succeeded when called from a user input handler"
+ );
+ handle.destruct();
+
+ await extension.unload();
+});
+
+// Test that the schema requireUserInput flag works correctly for
+// non-proxied api implementations.
+add_task(async function test_local() {
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ background() {
+ browser.test.onMessage.addListener(async () => {
+ try {
+ await browser.userinputtest.child();
+ browser.test.sendMessage("result", null);
+ } catch (err) {
+ browser.test.sendMessage("result", err.message);
+ }
+ });
+ },
+ manifest: {
+ experiment_apis: experimentAPIs,
+ },
+ files: experimentFiles,
+ });
+
+ await extension.startup();
+
+ extension.sendMessage("test");
+ let result = await extension.awaitMessage("result");
+ ok(
+ /child may only be called from a user input handler/.test(result),
+ `function failed when not called from a user input handler: ${result}`
+ );
+
+ let handle = setHandlingUserInput(extension);
+ extension.sendMessage("test");
+ result = await extension.awaitMessage("result");
+ equal(
+ result,
+ null,
+ "function succeeded when called from a user input handler"
+ );
+ handle.destruct();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js
new file mode 100644
index 0000000000..562ab5c36d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js
@@ -0,0 +1,171 @@
+"use strict";
+
+const { ExtensionAPI } = ExtensionCommon;
+
+add_task(async function () {
+ const schema = [
+ {
+ namespace: "manifest",
+ types: [
+ {
+ $extend: "WebExtensionManifest",
+ properties: {
+ a_manifest_property: {
+ type: "object",
+ optional: true,
+ properties: {
+ nested: {
+ optional: true,
+ type: "any",
+ },
+ },
+ additionalProperties: { $ref: "UnrecognizedProperty" },
+ },
+ },
+ },
+ ],
+ },
+ {
+ namespace: "testManifestPermission",
+ permissions: ["manifest:a_manifest_property"],
+ functions: [
+ {
+ name: "testMethod",
+ type: "function",
+ async: true,
+ parameters: [],
+ permissions: ["manifest:a_manifest_property.nested"],
+ },
+ ],
+ },
+ ];
+
+ class FakeAPI extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ testManifestPermission: {
+ get testProperty() {
+ return "value";
+ },
+ testMethod() {
+ return Promise.resolve("value");
+ },
+ },
+ };
+ }
+ }
+
+ const modules = {
+ testNamespace: {
+ url: URL.createObjectURL(new Blob([FakeAPI.toString()])),
+ schema: `data:,${JSON.stringify(schema)}`,
+ scopes: ["addon_parent", "addon_child"],
+ paths: [["testManifestPermission"]],
+ },
+ };
+
+ Services.catMan.addCategoryEntry(
+ "webextension-modules",
+ "test-manifest-permission",
+ `data:,${JSON.stringify(modules)}`,
+ false,
+ false
+ );
+
+ async function testExtension(extensionDef, assertFn) {
+ let extension = ExtensionTestUtils.loadExtension(extensionDef);
+
+ await extension.startup();
+ await assertFn(extension);
+ await extension.unload();
+ }
+
+ await testExtension(
+ {
+ manifest: {
+ a_manifest_property: {},
+ },
+ background() {
+ // Test hasPermission method implemented in ExtensionChild.jsm.
+ browser.test.assertTrue(
+ "testManifestPermission" in browser,
+ "The API namespace is defined as expected"
+ );
+ browser.test.assertEq(
+ undefined,
+ browser.testManifestPermission &&
+ browser.testManifestPermission.testMethod,
+ "The property with nested manifest property permission should not be available "
+ );
+ browser.test.notifyPass("test-extension-manifest-without-nested-prop");
+ },
+ },
+ async extension => {
+ await extension.awaitFinish(
+ "test-extension-manifest-without-nested-prop"
+ );
+
+ // Test hasPermission method implemented in Extension.jsm.
+ equal(
+ extension.extension.hasPermission("manifest:a_manifest_property"),
+ true,
+ "Got the expected Extension's hasPermission result on existing property"
+ );
+ equal(
+ extension.extension.hasPermission(
+ "manifest:a_manifest_property.nested"
+ ),
+ false,
+ "Got the expected Extension's hasPermission result on existing subproperty"
+ );
+ }
+ );
+
+ await testExtension(
+ {
+ manifest: {
+ a_manifest_property: {
+ nested: {},
+ },
+ },
+ background() {
+ // Test hasPermission method implemented in ExtensionChild.jsm.
+ browser.test.assertTrue(
+ "testManifestPermission" in browser,
+ "The API namespace is defined as expected"
+ );
+ browser.test.assertEq(
+ "function",
+ browser.testManifestPermission &&
+ typeof browser.testManifestPermission.testMethod,
+ "The property with nested manifest property permission should be available "
+ );
+ browser.test.notifyPass("test-extension-manifest-with-nested-prop");
+ },
+ },
+ async extension => {
+ await extension.awaitFinish("test-extension-manifest-with-nested-prop");
+
+ // Test hasPermission method implemented in Extension.jsm.
+ equal(
+ extension.extension.hasPermission("manifest:a_manifest_property"),
+ true,
+ "Got the expected Extension's hasPermission result on existing property"
+ );
+ equal(
+ extension.extension.hasPermission(
+ "manifest:a_manifest_property.nested"
+ ),
+ true,
+ "Got the expected Extension's hasPermission result on existing subproperty"
+ );
+ equal(
+ extension.extension.hasPermission(
+ "manifest:a_manifest_property.unexisting"
+ ),
+ false,
+ "Got the expected Extension's hasPermission result on non existing subproperty"
+ );
+ }
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js
new file mode 100644
index 0000000000..e2da7e5a74
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js
@@ -0,0 +1,161 @@
+"use strict";
+
+const { ExtensionAPI } = ExtensionCommon;
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.usePrivilegedSignatures = false;
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_setup(async () => {
+ const schema = [
+ {
+ namespace: "privileged",
+ permissions: ["mozillaAddons"],
+ properties: {
+ test: {
+ type: "any",
+ },
+ },
+ },
+ ];
+
+ class API extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ privileged: {
+ test: "hello",
+ },
+ };
+ }
+ }
+
+ const modules = {
+ privileged: {
+ url: URL.createObjectURL(new Blob([API.toString()])),
+ schema: `data:,${JSON.stringify(schema)}`,
+ scopes: ["addon_parent"],
+ paths: [["privileged"]],
+ },
+ };
+
+ Services.catMan.addCategoryEntry(
+ "webextension-modules",
+ "test-privileged",
+ `data:,${JSON.stringify(modules)}`,
+ false,
+ false
+ );
+
+ await AddonTestUtils.promiseStartupManager();
+
+ registerCleanupFunction(async () => {
+ await AddonTestUtils.promiseShutdownManager();
+ Services.catMan.deleteCategoryEntry(
+ "webextension-modules",
+ "test-privileged",
+ false
+ );
+ });
+});
+
+add_task(
+ {
+ // Some builds (e.g. thunderbird) have experiments enabled by default.
+ pref_set: [["extensions.experiments.enabled", false]],
+ },
+ async function test_privileged_namespace_disallowed() {
+ // Try accessing the privileged namespace.
+ async function testOnce({
+ isPrivileged = false,
+ temporarilyInstalled = false,
+ } = {}) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["mozillaAddons", "tabs"],
+ },
+ background() {
+ browser.test.sendMessage(
+ "result",
+ browser.privileged instanceof Object
+ );
+ },
+ isPrivileged,
+ temporarilyInstalled,
+ });
+
+ if (temporarilyInstalled && !isPrivileged) {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ let { messages } = await promiseConsoleOutput(async () => {
+ await Assert.rejects(
+ extension.startup(),
+ /Using the privileged permission/,
+ "Startup failed with privileged permission"
+ );
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ AddonTestUtils.checkMessages(
+ messages,
+ {
+ expected: [
+ {
+ message:
+ /Using the privileged permission 'mozillaAddons' requires a privileged add-on/,
+ },
+ ],
+ },
+ true
+ );
+ return null;
+ }
+ await extension.startup();
+ let result = await extension.awaitMessage("result");
+ await extension.unload();
+ return result;
+ }
+
+ // Prevents startup
+ let result = await testOnce({ temporarilyInstalled: true });
+ equal(
+ result,
+ null,
+ "Privileged namespace should not be accessible to a regular webextension"
+ );
+
+ result = await testOnce({ isPrivileged: true });
+ equal(
+ result,
+ true,
+ "Privileged namespace should be accessible to a webextension signed with Mozilla Extensions"
+ );
+
+ // Allows startup, no access
+ result = await testOnce();
+ equal(
+ result,
+ false,
+ "Privileged namespace should not be accessible to a regular webextension"
+ );
+ }
+);
+
+// Test that Extension.jsm and schema correctly match.
+add_task(function test_privileged_permissions_match() {
+ const { PRIVILEGED_PERMS } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+ );
+ let perms = Schemas.getPermissionNames(["PermissionPrivileged"]);
+ if (AppConstants.platform == "android") {
+ perms.push("nativeMessaging");
+ }
+ Assert.deepEqual(
+ Array.from(PRIVILEGED_PERMS).sort(),
+ perms.sort(),
+ "List of privileged permissions is correct."
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js
new file mode 100644
index 0000000000..5b4df1e671
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js
@@ -0,0 +1,507 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { Schemas } = ChromeUtils.importESModule(
+ "resource://gre/modules/Schemas.sys.mjs"
+);
+
+let { SchemaAPIInterface } = ExtensionCommon;
+
+const global = this;
+
+let json = [
+ {
+ namespace: "revokableNs",
+
+ permissions: ["revokableNs"],
+
+ properties: {
+ stringProp: {
+ type: "string",
+ writable: true,
+ },
+
+ revokableStringProp: {
+ type: "string",
+ permissions: ["revokableProp"],
+ writable: true,
+ },
+
+ submoduleProp: {
+ $ref: "submodule",
+ },
+
+ revokableSubmoduleProp: {
+ $ref: "submodule",
+ permissions: ["revokableProp"],
+ },
+ },
+
+ types: [
+ {
+ id: "submodule",
+ type: "object",
+ functions: [
+ {
+ name: "sub_foo",
+ type: "function",
+ parameters: [],
+ returns: { type: "integer" },
+ },
+ ],
+ },
+ ],
+
+ functions: [
+ {
+ name: "func",
+ type: "function",
+ parameters: [],
+ },
+
+ {
+ name: "revokableFunc",
+ type: "function",
+ parameters: [],
+ permissions: ["revokableFunc"],
+ },
+ ],
+
+ events: [
+ {
+ name: "onEvent",
+ type: "function",
+ },
+
+ {
+ name: "onRevokableEvent",
+ type: "function",
+ permissions: ["revokableEvent"],
+ },
+ ],
+ },
+];
+
+let recorded = [];
+
+function record(...args) {
+ recorded.push(args);
+}
+
+function verify(expected) {
+ for (let [i, rec] of expected.entries()) {
+ Assert.deepEqual(recorded[i], rec, `Record ${i} matches`);
+ }
+
+ equal(recorded.length, expected.length, "Got expected number of records");
+
+ recorded.length = 0;
+}
+
+registerCleanupFunction(() => {
+ equal(recorded.length, 0, "No unchecked recorded events at shutdown");
+});
+
+let permissions = new Set();
+
+class APIImplementation extends SchemaAPIInterface {
+ constructor(namespace, name) {
+ super();
+ this.namespace = namespace;
+ this.name = name;
+ }
+
+ record(method, args) {
+ record(method, this.namespace, this.name, args);
+ }
+
+ revoke(...args) {
+ this.record("revoke", args);
+ }
+
+ callFunction(...args) {
+ this.record("callFunction", args);
+ if (this.name === "sub_foo") {
+ return 13;
+ }
+ }
+
+ callFunctionNoReturn(...args) {
+ this.record("callFunctionNoReturn", args);
+ }
+
+ getProperty(...args) {
+ this.record("getProperty", args);
+ }
+
+ setProperty(...args) {
+ this.record("setProperty", args);
+ }
+
+ addListener(...args) {
+ this.record("addListener", args);
+ }
+
+ removeListener(...args) {
+ this.record("removeListener", args);
+ }
+
+ hasListener(...args) {
+ this.record("hasListener", args);
+ }
+}
+
+let context = {
+ manifestVersion: 2,
+ cloneScope: global,
+
+ permissionsChanged: null,
+
+ setPermissionsChangedCallback(callback) {
+ this.permissionsChanged = callback;
+ },
+
+ hasPermission(permission) {
+ return permissions.has(permission);
+ },
+
+ isPermissionRevokable(permission) {
+ return permission.startsWith("revokable");
+ },
+
+ getImplementation(namespace, name) {
+ return new APIImplementation(namespace, name);
+ },
+
+ shouldInject() {
+ return true;
+ },
+};
+
+function ignoreError(fn) {
+ try {
+ fn();
+ } catch (e) {
+ // Meh.
+ }
+}
+
+add_task(async function () {
+ let url = "data:," + JSON.stringify(json);
+ await Schemas.load(url);
+
+ let root = {};
+ Schemas.inject(root, context);
+ equal(recorded.length, 0, "No recorded events");
+
+ let listener = () => {};
+ let captured = {};
+
+ function checkRecorded() {
+ let possible = [
+ ["revokableNs", ["getProperty", "revokableNs", "stringProp", []]],
+ [
+ "revokableProp",
+ ["getProperty", "revokableNs", "revokableStringProp", []],
+ ],
+
+ [
+ "revokableNs",
+ ["setProperty", "revokableNs", "stringProp", ["stringProp"]],
+ ],
+ [
+ "revokableProp",
+ [
+ "setProperty",
+ "revokableNs",
+ "revokableStringProp",
+ ["revokableStringProp"],
+ ],
+ ],
+
+ ["revokableNs", ["callFunctionNoReturn", "revokableNs", "func", [[]]]],
+ [
+ "revokableFunc",
+ ["callFunctionNoReturn", "revokableNs", "revokableFunc", [[]]],
+ ],
+
+ [
+ "revokableNs",
+ ["callFunction", "revokableNs.submoduleProp", "sub_foo", [[]]],
+ ],
+ [
+ "revokableProp",
+ ["callFunction", "revokableNs.revokableSubmoduleProp", "sub_foo", [[]]],
+ ],
+
+ [
+ "revokableNs",
+ ["addListener", "revokableNs", "onEvent", [listener, []]],
+ ],
+ ["revokableNs", ["removeListener", "revokableNs", "onEvent", [listener]]],
+ ["revokableNs", ["hasListener", "revokableNs", "onEvent", [listener]]],
+
+ [
+ "revokableEvent",
+ ["addListener", "revokableNs", "onRevokableEvent", [listener, []]],
+ ],
+ [
+ "revokableEvent",
+ ["removeListener", "revokableNs", "onRevokableEvent", [listener]],
+ ],
+ [
+ "revokableEvent",
+ ["hasListener", "revokableNs", "onRevokableEvent", [listener]],
+ ],
+ ];
+
+ let expected = [];
+ if (permissions.has("revokableNs")) {
+ for (let [perm, recording] of possible) {
+ if (!perm || permissions.has(perm)) {
+ expected.push(recording);
+ }
+ }
+ }
+
+ verify(expected);
+ }
+
+ function check() {
+ info(`Check normal access (permissions: [${Array.from(permissions)}])`);
+
+ let ns = root.revokableNs;
+
+ void ns.stringProp;
+ void ns.revokableStringProp;
+
+ ns.stringProp = "stringProp";
+ ns.revokableStringProp = "revokableStringProp";
+
+ ns.func();
+
+ if (ns.revokableFunc) {
+ ns.revokableFunc();
+ }
+
+ ns.submoduleProp.sub_foo();
+ if (ns.revokableSubmoduleProp) {
+ ns.revokableSubmoduleProp.sub_foo();
+ }
+
+ ns.onEvent.addListener(listener);
+ ns.onEvent.removeListener(listener);
+ ns.onEvent.hasListener(listener);
+
+ if (ns.onRevokableEvent) {
+ ns.onRevokableEvent.addListener(listener);
+ ns.onRevokableEvent.removeListener(listener);
+ ns.onRevokableEvent.hasListener(listener);
+ }
+
+ checkRecorded();
+ }
+
+ function capture() {
+ info("Capture values");
+
+ let ns = root.revokableNs;
+
+ captured = { ns };
+ captured.revokableStringProp = Object.getOwnPropertyDescriptor(
+ ns,
+ "revokableStringProp"
+ );
+
+ captured.revokableSubmoduleProp = ns.revokableSubmoduleProp;
+ if (ns.revokableSubmoduleProp) {
+ captured.sub_foo = ns.revokableSubmoduleProp.sub_foo;
+ }
+
+ captured.revokableFunc = ns.revokableFunc;
+
+ captured.onRevokableEvent = ns.onRevokableEvent;
+ if (ns.onRevokableEvent) {
+ captured.addListener = ns.onRevokableEvent.addListener;
+ captured.removeListener = ns.onRevokableEvent.removeListener;
+ captured.hasListener = ns.onRevokableEvent.hasListener;
+ }
+ }
+
+ function checkCaptured() {
+ info(
+ `Check captured value access (permissions: [${Array.from(permissions)}])`
+ );
+
+ let { ns } = captured;
+
+ void ns.stringProp;
+ ignoreError(() => captured.revokableStringProp.get());
+ if (!permissions.has("revokableProp")) {
+ void ns.revokableStringProp;
+ }
+
+ ns.stringProp = "stringProp";
+ ignoreError(() => captured.revokableStringProp.set("revokableStringProp"));
+ if (!permissions.has("revokableProp")) {
+ ns.revokableStringProp = "revokableStringProp";
+ }
+
+ ignoreError(() => ns.func());
+ ignoreError(() => captured.revokableFunc());
+ if (!permissions.has("revokableFunc")) {
+ ignoreError(() => ns.revokableFunc());
+ }
+
+ ignoreError(() => ns.submoduleProp.sub_foo());
+
+ ignoreError(() => captured.sub_foo());
+ if (!permissions.has("revokableProp")) {
+ ignoreError(() => captured.revokableSubmoduleProp.sub_foo());
+ ignoreError(() => ns.revokableSubmoduleProp.sub_foo());
+ }
+
+ ignoreError(() => ns.onEvent.addListener(listener));
+ ignoreError(() => ns.onEvent.removeListener(listener));
+ ignoreError(() => ns.onEvent.hasListener(listener));
+
+ ignoreError(() => captured.addListener(listener));
+ ignoreError(() => captured.removeListener(listener));
+ ignoreError(() => captured.hasListener(listener));
+ if (!permissions.has("revokableEvent")) {
+ ignoreError(() => captured.onRevokableEvent.addListener(listener));
+ ignoreError(() => captured.onRevokableEvent.removeListener(listener));
+ ignoreError(() => captured.onRevokableEvent.hasListener(listener));
+
+ ignoreError(() => ns.onRevokableEvent.addListener(listener));
+ ignoreError(() => ns.onRevokableEvent.removeListener(listener));
+ ignoreError(() => ns.onRevokableEvent.hasListener(listener));
+ }
+
+ checkRecorded();
+ }
+
+ permissions.add("revokableNs");
+ permissions.add("revokableProp");
+ permissions.add("revokableFunc");
+ permissions.add("revokableEvent");
+
+ check();
+ capture();
+ checkCaptured();
+
+ permissions.delete("revokableProp");
+ context.permissionsChanged();
+ verify([
+ ["revoke", "revokableNs", "revokableStringProp", []],
+ ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []],
+ ]);
+
+ check();
+ checkCaptured();
+
+ permissions.delete("revokableFunc");
+ context.permissionsChanged();
+ verify([["revoke", "revokableNs", "revokableFunc", []]]);
+
+ check();
+ checkCaptured();
+
+ permissions.delete("revokableEvent");
+ context.permissionsChanged();
+
+ verify([["revoke", "revokableNs", "onRevokableEvent", []]]);
+
+ check();
+ checkCaptured();
+
+ permissions.delete("revokableNs");
+ context.permissionsChanged();
+ verify([
+ ["revoke", "revokableNs", "stringProp", []],
+ ["revoke", "revokableNs", "func", []],
+ ["revoke", "revokableNs.submoduleProp", "sub_foo", []],
+ ["revoke", "revokableNs", "onEvent", []],
+ ]);
+
+ checkCaptured();
+
+ permissions.add("revokableNs");
+ permissions.add("revokableProp");
+ permissions.add("revokableFunc");
+ permissions.add("revokableEvent");
+ context.permissionsChanged();
+
+ check();
+ capture();
+ checkCaptured();
+
+ permissions.delete("revokableProp");
+ permissions.delete("revokableFunc");
+ permissions.delete("revokableEvent");
+ context.permissionsChanged();
+ verify([
+ ["revoke", "revokableNs", "revokableStringProp", []],
+ ["revoke", "revokableNs", "revokableFunc", []],
+ ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []],
+ ["revoke", "revokableNs", "onRevokableEvent", []],
+ ]);
+
+ check();
+ checkCaptured();
+
+ permissions.add("revokableProp");
+ permissions.add("revokableFunc");
+ permissions.add("revokableEvent");
+ context.permissionsChanged();
+
+ check();
+ capture();
+ checkCaptured();
+
+ permissions.delete("revokableNs");
+ context.permissionsChanged();
+ verify([
+ ["revoke", "revokableNs", "stringProp", []],
+ ["revoke", "revokableNs", "revokableStringProp", []],
+ ["revoke", "revokableNs", "func", []],
+ ["revoke", "revokableNs", "revokableFunc", []],
+ ["revoke", "revokableNs.submoduleProp", "sub_foo", []],
+ ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []],
+ ["revoke", "revokableNs", "onEvent", []],
+ ["revoke", "revokableNs", "onRevokableEvent", []],
+ ]);
+
+ equal(root.revokableNs, undefined, "Namespace is not defined");
+ checkCaptured();
+});
+
+add_task(async function test_neuter() {
+ context.permissionsChanged = null;
+
+ let root = {};
+ Schemas.inject(root, context);
+ equal(recorded.length, 0, "No recorded events");
+
+ permissions.add("revokableNs");
+ permissions.add("revokableProp");
+ permissions.add("revokableFunc");
+ permissions.add("revokableEvent");
+
+ let ns = root.revokableNs;
+ let { submoduleProp } = ns;
+
+ let lazyGetter = Object.getOwnPropertyDescriptor(submoduleProp, "sub_foo");
+
+ permissions.delete("revokableNs");
+ context.permissionsChanged();
+ verify([]);
+
+ equal(root.revokableNs, undefined, "Should have no revokableNs");
+ equal(ns.submoduleProp, undefined, "Should have no ns.submoduleProp");
+
+ equal(submoduleProp.sub_foo, undefined, "No sub_foo");
+ lazyGetter.get.call(submoduleProp);
+ equal(submoduleProp.sub_foo, undefined, "No sub_foo");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js
new file mode 100644
index 0000000000..21434228a3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js
@@ -0,0 +1,242 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { SchemaRoot } = ChromeUtils.importESModule(
+ "resource://gre/modules/Schemas.sys.mjs"
+);
+
+let { SchemaAPIInterface } = ExtensionCommon;
+
+const global = this;
+
+let baseSchemaJSON = [
+ {
+ namespace: "base",
+
+ properties: {
+ PROP1: { value: 42 },
+ },
+
+ types: [
+ {
+ id: "type1",
+ type: "string",
+ enum: ["value1", "value2", "value3"],
+ },
+ ],
+
+ functions: [
+ {
+ name: "foo",
+ type: "function",
+ parameters: [{ name: "arg1", $ref: "type1" }],
+ },
+ ],
+ },
+];
+
+let experimentFooJSON = [
+ {
+ namespace: "experiments.foo",
+ types: [
+ {
+ id: "typeFoo",
+ type: "string",
+ enum: ["foo1", "foo2", "foo3"],
+ },
+ ],
+
+ functions: [
+ {
+ name: "foo",
+ type: "function",
+ parameters: [
+ { name: "arg1", $ref: "typeFoo" },
+ { name: "arg2", $ref: "base.type1" },
+ ],
+ },
+ ],
+ },
+];
+
+let experimentBarJSON = [
+ {
+ namespace: "experiments.bar",
+ types: [
+ {
+ id: "typeBar",
+ type: "string",
+ enum: ["bar1", "bar2", "bar3"],
+ },
+ ],
+
+ functions: [
+ {
+ name: "bar",
+ type: "function",
+ parameters: [
+ { name: "arg1", $ref: "typeBar" },
+ { name: "arg2", $ref: "base.type1" },
+ ],
+ },
+ ],
+ },
+];
+
+let tallied = null;
+
+function tally(kind, ns, name, args) {
+ tallied = [kind, ns, name, args];
+}
+
+function verify(...args) {
+ equal(JSON.stringify(tallied), JSON.stringify(args));
+ tallied = null;
+}
+
+let talliedErrors = [];
+
+let permissions = new Set();
+
+class TallyingAPIImplementation extends SchemaAPIInterface {
+ constructor(namespace, name) {
+ super();
+ this.namespace = namespace;
+ this.name = name;
+ }
+
+ callFunction(args) {
+ tally("call", this.namespace, this.name, args);
+ if (this.name === "sub_foo") {
+ return 13;
+ }
+ }
+
+ callFunctionNoReturn(args) {
+ tally("call", this.namespace, this.name, args);
+ }
+
+ getProperty() {
+ tally("get", this.namespace, this.name);
+ }
+
+ setProperty(value) {
+ tally("set", this.namespace, this.name, value);
+ }
+
+ addListener(listener, args) {
+ tally("addListener", this.namespace, this.name, [listener, args]);
+ }
+
+ removeListener(listener) {
+ tally("removeListener", this.namespace, this.name, [listener]);
+ }
+
+ hasListener(listener) {
+ tally("hasListener", this.namespace, this.name, [listener]);
+ }
+}
+
+let wrapper = {
+ url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/",
+ manifestVersion: 2,
+
+ cloneScope: global,
+
+ checkLoadURL(url) {
+ return !url.startsWith("chrome:");
+ },
+
+ preprocessors: {
+ localize(value, context) {
+ return value.replace(/__MSG_(.*?)__/g, (m0, m1) => `${m1.toUpperCase()}`);
+ },
+ },
+
+ logError(message) {
+ talliedErrors.push(message);
+ },
+
+ hasPermission(permission) {
+ return permissions.has(permission);
+ },
+
+ shouldInject(ns, name) {
+ return name != "do-not-inject";
+ },
+
+ getImplementation(namespace, name) {
+ return new TallyingAPIImplementation(namespace, name);
+ },
+};
+
+add_task(async function () {
+ let baseSchemas = new Map([["resource://schemas/base.json", baseSchemaJSON]]);
+ let experimentSchemas = new Map([
+ ["resource://experiment-foo/schema.json", experimentFooJSON],
+ ["resource://experiment-bar/schema.json", experimentBarJSON],
+ ]);
+
+ let baseSchema = new SchemaRoot(null, baseSchemas);
+ let schema = new SchemaRoot(baseSchema, experimentSchemas);
+
+ baseSchema.parseSchemas();
+ schema.parseSchemas();
+
+ let root = {};
+ let base = {};
+
+ tallied = null;
+
+ baseSchema.inject(base, wrapper);
+ schema.inject(root, wrapper);
+
+ equal(typeof base.base, "object", "base.base exists");
+ equal(typeof root.base, "object", "root.base exists");
+ equal(typeof base.experiments, "undefined", "base.experiments exists not");
+ equal(typeof root.experiments, "object", "root.experiments exists");
+ equal(typeof root.experiments.foo, "object", "root.experiments.foo exists");
+ equal(typeof root.experiments.bar, "object", "root.experiments.bar exists");
+
+ equal(tallied, null);
+
+ equal(root.base.PROP1, 42, "root.base.PROP1");
+ equal(base.base.PROP1, 42, "root.base.PROP1");
+
+ root.base.foo("value2");
+ verify("call", "base", "foo", ["value2"]);
+
+ base.base.foo("value3");
+ verify("call", "base", "foo", ["value3"]);
+
+ root.experiments.foo.foo("foo2", "value1");
+ verify("call", "experiments.foo", "foo", ["foo2", "value1"]);
+
+ root.experiments.bar.bar("bar2", "value1");
+ verify("call", "experiments.bar", "bar", ["bar2", "value1"]);
+
+ Assert.throws(
+ () => root.base.foo("Meh."),
+ /Type error for parameter arg1/,
+ "root.base.foo()"
+ );
+
+ Assert.throws(
+ () => base.base.foo("Meh."),
+ /Type error for parameter arg1/,
+ "base.base.foo()"
+ );
+
+ Assert.throws(
+ () => root.experiments.foo.foo("Meh."),
+ /Incorrect argument types/,
+ "root.experiments.foo.foo()"
+ );
+
+ Assert.throws(
+ () => root.experiments.bar.bar("Meh."),
+ /Incorrect argument types/,
+ "root.experiments.bar.bar()"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_versioned.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_versioned.js
new file mode 100644
index 0000000000..3dddbbc41b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_versioned.js
@@ -0,0 +1,714 @@
+"use strict";
+
+let json = [
+ {
+ namespace: "MV2",
+ max_manifest_version: 2,
+
+ properties: {
+ PROP1: { value: 20 },
+ },
+ },
+ {
+ namespace: "MV3",
+ min_manifest_version: 3,
+ properties: {
+ PROP1: { value: 20 },
+ },
+ },
+ {
+ namespace: "mixed",
+
+ properties: {
+ PROP_any: { value: 20 },
+ PROP_mv3: {
+ $ref: "submodule",
+ },
+ },
+ types: [
+ {
+ id: "manifestTest",
+ type: "object",
+ properties: {
+ // An example of extending the base type for permissions
+ permissions: {
+ type: "array",
+ items: {
+ $ref: "BaseType",
+ },
+ optional: true,
+ default: [],
+ },
+ // An example of differentiating versions of a manifest entry
+ multiple_choice: {
+ optional: true,
+ choices: [
+ {
+ max_manifest_version: 2,
+ type: "array",
+ items: {
+ type: "string",
+ },
+ },
+ {
+ min_manifest_version: 3,
+ type: "array",
+ items: {
+ type: "boolean",
+ },
+ },
+ {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ value: { type: "boolean" },
+ },
+ },
+ },
+ ],
+ },
+ accepting_unrecognized_props: {
+ optional: true,
+ type: "object",
+ properties: {
+ mv2_only_prop: {
+ type: "string",
+ optional: true,
+ max_manifest_version: 2,
+ },
+ mv3_only_prop: {
+ type: "string",
+ optional: true,
+ min_manifest_version: 3,
+ },
+ mv2_only_prop_with_default: {
+ type: "string",
+ optional: true,
+ default: "only in MV2",
+ max_manifest_version: 2,
+ },
+ mv3_only_prop_with_default: {
+ type: "string",
+ optional: true,
+ default: "only in MV3",
+ min_manifest_version: 3,
+ },
+ },
+ additionalProperties: { $ref: "UnrecognizedProperty" },
+ },
+ },
+ },
+ {
+ id: "submodule",
+ type: "object",
+ min_manifest_version: 3,
+ functions: [
+ {
+ name: "sub_foo",
+ type: "function",
+ parameters: [],
+ returns: { type: "integer" },
+ },
+ {
+ name: "sub_no_match",
+ type: "function",
+ max_manifest_version: 2,
+ parameters: [],
+ returns: { type: "integer" },
+ },
+ ],
+ },
+ {
+ id: "BaseType",
+ choices: [
+ {
+ type: "string",
+ enum: ["base"],
+ },
+ ],
+ },
+ {
+ id: "type_any",
+ type: "string",
+ enum: ["value1", "value2", "value3"],
+ },
+ {
+ id: "type_mv2",
+ max_manifest_version: 2,
+ type: "string",
+ enum: ["value1", "value2", "value3"],
+ },
+ {
+ id: "type_mv3",
+ min_manifest_version: 3,
+ type: "string",
+ enum: ["value1", "value2", "value3"],
+ },
+ {
+ id: "param_type_changed",
+ type: "array",
+ items: {
+ choices: [
+ { max_manifest_version: 2, type: "string" },
+ {
+ min_manifest_version: 3,
+ type: "boolean",
+ },
+ ],
+ },
+ },
+ {
+ id: "object_type_changed",
+ type: "object",
+ properties: {
+ prop_mv2: {
+ type: "string",
+ max_manifest_version: 2,
+ },
+ prop_mv3: {
+ type: "string",
+ min_manifest_version: 3,
+ },
+ prop_any: {
+ type: "string",
+ },
+ },
+ },
+ {
+ id: "no_valid_choices",
+ type: "array",
+ items: {
+ choices: [
+ { max_manifest_version: 1, type: "string" },
+ {
+ min_manifest_version: 4,
+ type: "boolean",
+ },
+ ],
+ },
+ },
+ ],
+
+ functions: [
+ {
+ name: "fun_param_type_versioned",
+ type: "function",
+ parameters: [{ name: "arg1", $ref: "param_type_changed" }],
+ },
+ {
+ name: "fun_mv2",
+ max_manifest_version: 2,
+ type: "function",
+ parameters: [
+ { name: "arg1", type: "integer", optional: true, default: 99 },
+ { name: "arg2", type: "boolean", optional: true },
+ ],
+ },
+ {
+ name: "fun_mv3",
+ min_manifest_version: 3,
+ type: "function",
+ parameters: [
+ { name: "arg1", type: "integer", optional: true, default: 99 },
+ { name: "arg2", type: "boolean", optional: true },
+ ],
+ },
+ {
+ name: "fun_param_change",
+ type: "function",
+ parameters: [{ name: "arg1", $ref: "object_type_changed" }],
+ },
+ {
+ name: "fun_no_valid_param",
+ type: "function",
+ parameters: [{ name: "arg1", $ref: "no_valid_choices" }],
+ },
+ ],
+ events: [
+ {
+ name: "onEvent_any",
+ type: "function",
+ },
+ {
+ name: "onEvent_mv2",
+ max_manifest_version: 2,
+ type: "function",
+ },
+ {
+ name: "onEvent_mv3",
+ min_manifest_version: 3,
+ type: "function",
+ },
+ ],
+ },
+ {
+ namespace: "mixed",
+ types: [
+ {
+ $extend: "BaseType",
+ choices: [
+ {
+ min_manifest_version: 3,
+ type: "string",
+ enum: ["extended"],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ namespace: "mixed",
+ types: [
+ {
+ $extend: "manifestTest",
+ properties: {
+ versioned_extend: {
+ optional: true,
+ // just a simple type here
+ type: "string",
+ max_manifest_version: 2,
+ },
+ },
+ },
+ ],
+ },
+];
+
+add_task(async function setup() {
+ let url = "data:," + JSON.stringify(json);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ // We want the actual errors thrown here, and not warnings recast as errors.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+});
+
+add_task(async function test_inject_V2() {
+ // Test injecting into a V2 context.
+ let wrapper = getContextWrapper(2);
+
+ let root = {};
+ Schemas.inject(root, wrapper);
+
+ // Test elements available to both
+ Assert.equal(root.mixed.type_any.VALUE1, "value1", "type_any exists");
+ Assert.equal(root.mixed.PROP_any, 20, "mixed value property");
+
+ // Test elements available to MV2
+ Assert.equal(root.MV2.PROP1, 20, "MV2 value property");
+ Assert.equal(root.mixed.type_mv2.VALUE2, "value2", "type_mv2 exists");
+
+ // Test MV3 elements not available
+ Assert.equal(root.MV3, undefined, "MV3 not injected");
+ Assert.ok(!("MV3" in root), "MV3 not enumerable");
+ Assert.equal(
+ root.mixed.PROP_mv3,
+ undefined,
+ "mixed submodule property does not exist"
+ );
+ Assert.ok(
+ !("PROP_mv3" in root.mixed),
+ "mixed submodule property not enumerable"
+ );
+ Assert.equal(root.mixed.type_mv3, undefined, "type_mv3 does not exist");
+
+ // Function tests
+ Assert.ok(
+ "fun_param_type_versioned" in root.mixed,
+ "fun_param_type_versioned exists"
+ );
+ Assert.ok(
+ !!root.mixed.fun_param_type_versioned,
+ "fun_param_type_versioned exists"
+ );
+ Assert.ok("fun_mv2" in root.mixed, "fun_mv2 exists");
+ Assert.ok(!!root.mixed.fun_mv2, "fun_mv2 exists");
+ Assert.ok(!("fun_mv3" in root.mixed), "fun_mv3 does not exist");
+ Assert.ok(!root.mixed.fun_mv3, "fun_mv3 does not exist");
+
+ // Event tests
+ Assert.ok("onEvent_any" in root.mixed, "onEvent_any exists");
+ Assert.ok(!!root.mixed.onEvent_any, "onEvent_any exists");
+ Assert.ok("onEvent_mv2" in root.mixed, "onEvent_mv2 exists");
+ Assert.ok(!!root.mixed.onEvent_mv2, "onEvent_mv2 exists");
+ Assert.ok(!("onEvent_mv3" in root.mixed), "onEvent_mv3 does not exist");
+ Assert.ok(!root.mixed.onEvent_mv3, "onEvent_mv3 does not exist");
+
+ // Function call tests
+ root.mixed.fun_param_type_versioned(["hello"]);
+ wrapper.verify("call", "mixed", "fun_param_type_versioned", [["hello"]]);
+ Assert.throws(
+ () => root.mixed.fun_param_type_versioned([true]),
+ /Expected string instead of true/,
+ "fun_param_type_versioned should throw for invalid type"
+ );
+
+ let propObj = { prop_any: "prop_any", prop_mv2: "prop_mv2" };
+ root.mixed.fun_param_change(propObj);
+ wrapper.verify("call", "mixed", "fun_param_change", [propObj]);
+
+ // Still throw same error as we did before we knew of the MV3 property.
+ Assert.throws(
+ () => root.mixed.fun_param_change({ prop_mv3: "prop_mv3", ...propObj }),
+ /Type error for parameter arg1 \(Unexpected property "prop_mv3"\)/,
+ "generic unexpected property message for MV3 property in MV2 extension"
+ );
+
+ // But print the more specific and descriptive warning message to console.
+ wrapper.checkErrors([
+ `Property "prop_mv3" is unsupported in Manifest Version 2`,
+ ]);
+
+ Assert.throws(
+ () => root.mixed.fun_no_valid_param("anything"),
+ /Incorrect argument types for mixed.fun_no_valid_param/,
+ "fun_no_valid_param should throw for versioned type"
+ );
+});
+
+function normalizeTest(manifest, test, wrapper) {
+ let normalized = Schemas.normalize(manifest, "mixed.manifestTest", wrapper);
+ test(normalized);
+ // The test function should call wrapper.checkErrors if it expected errors.
+ // Here we call checkErrors again to ensure that there are not any unexpected
+ // errors left.
+ wrapper.checkErrors([]);
+}
+
+add_task(async function test_normalize_V2() {
+ let wrapper = getContextWrapper(2);
+
+ // Test normalize additions to the manifest structure
+ normalizeTest(
+ {
+ versioned_extend: "test",
+ },
+ normalized => {
+ Assert.equal(
+ normalized.value.versioned_extend,
+ "test",
+ "resources normalized"
+ );
+ },
+ wrapper
+ );
+
+ // Test normalizing baseType
+ normalizeTest(
+ {
+ permissions: ["base"],
+ },
+ normalized => {
+ Assert.equal(
+ normalized.value.permissions[0],
+ "base",
+ "resources normalized"
+ );
+ },
+ wrapper
+ );
+
+ normalizeTest(
+ {
+ permissions: ["extended"],
+ },
+ normalized => {
+ Assert.ok(
+ normalized.error.startsWith("Error processing permissions.0"),
+ `manifest normalized error ${normalized.error}`
+ );
+ },
+ wrapper
+ );
+
+ // Test normalizing a value
+ normalizeTest(
+ {
+ multiple_choice: ["foo.html"],
+ },
+ normalized => {
+ Assert.equal(
+ normalized.value.multiple_choice[0],
+ "foo.html",
+ "resources normalized"
+ );
+ },
+ wrapper
+ );
+
+ normalizeTest(
+ {
+ multiple_choice: [true],
+ },
+ normalized => {
+ Assert.ok(
+ normalized.error.startsWith("Error processing multiple_choice"),
+ "manifest error"
+ );
+ },
+ wrapper
+ );
+
+ normalizeTest(
+ {
+ multiple_choice: [
+ {
+ value: true,
+ },
+ ],
+ },
+ normalized => {
+ Assert.ok(
+ normalized.value.multiple_choice[0].value,
+ "resources normalized"
+ );
+ },
+ wrapper
+ );
+
+ // Tests that object definitions including additionalProperties can
+ // successfully accept objects from another manifest version, while ignoring
+ // the actual value from the non-matching manifest value.
+ normalizeTest(
+ {
+ accepting_unrecognized_props: {
+ mv2_only_prop: "mv2 here",
+ mv3_only_prop: "mv3 here",
+ },
+ },
+ normalized => {
+ equal(normalized.error, undefined, "no normalization error");
+ Assert.deepEqual(
+ normalized.value.accepting_unrecognized_props,
+ {
+ mv2_only_prop: "mv2 here",
+ mv2_only_prop_with_default: "only in MV2",
+ },
+ "Normalized object for MV2, without MV3-specific props"
+ );
+ wrapper.checkErrors([
+ `Property "mv3_only_prop" is unsupported in Manifest Version 2`,
+ ]);
+ },
+ wrapper
+ );
+});
+
+add_task(async function test_inject_V3() {
+ // Test injecting into a V3 context.
+ let wrapper = getContextWrapper(3);
+
+ let root = {};
+ Schemas.inject(root, wrapper);
+
+ // Test elements available to both
+ Assert.equal(root.mixed.type_any.VALUE1, "value1", "type_any exists");
+ Assert.equal(root.mixed.PROP_any, 20, "mixed value property");
+
+ // Test elements available to MV2
+ Assert.equal(root.MV2, undefined, "MV2 value property");
+ Assert.ok(!("MV2" in root), "MV2 not enumerable");
+ Assert.equal(root.mixed.type_mv2, undefined, "type_mv2 does not exist");
+ Assert.ok(!("type_mv2" in root.mixed), "type_mv2 not enumerable");
+
+ // Test MV3 elements not available
+ Assert.equal(root.MV3.PROP1, 20, "MV3 injected");
+ Assert.ok(!!root.mixed.PROP_mv3, "mixed submodule property exists");
+ Assert.equal(root.mixed.type_mv3.VALUE3, "value3", "type_mv3 exists");
+
+ // Versioned submodule
+ Assert.ok(!!root.mixed.PROP_mv3.sub_foo, "mixed submodule sub_foo exists");
+ Assert.ok(
+ !root.mixed.PROP_mv3.sub_no_match,
+ "mixed submodule sub_no_match does not exist"
+ );
+ Assert.ok(
+ !("sub_no_match" in root.mixed.PROP_mv3),
+ "mixed submodule sub_no_match is not enumerable"
+ );
+
+ // Function tests
+ Assert.ok(
+ "fun_param_type_versioned" in root.mixed,
+ "fun_param_type_versioned exists"
+ );
+ Assert.ok(
+ !!root.mixed.fun_param_type_versioned,
+ "fun_param_type_versioned exists"
+ );
+ Assert.ok(!("fun_mv2" in root.mixed), "fun_mv2 does not exist");
+ Assert.ok(!root.mixed.fun_mv2, "fun_mv2 does not exist");
+ Assert.ok("fun_mv3" in root.mixed, "fun_mv3 exists");
+ Assert.ok(!!root.mixed.fun_mv3, "fun_mv3 exists");
+
+ // Event tests
+ Assert.ok("onEvent_any" in root.mixed, "onEvent_any exists");
+ Assert.ok(!!root.mixed.onEvent_any, "onEvent_any exists");
+ Assert.ok(!("onEvent_mv2" in root.mixed), "onEvent_mv2 not enumerable");
+ Assert.ok(!root.mixed.onEvent_mv2, "onEvent_mv2 does not exist");
+ Assert.ok("onEvent_mv3" in root.mixed, "onEvent_mv3 exists");
+ Assert.ok(!!root.mixed.onEvent_mv3, "onEvent_mv3 exists");
+
+ // Function call tests
+ root.mixed.fun_param_type_versioned([true]);
+ wrapper.verify("call", "mixed", "fun_param_type_versioned", [[true]]);
+ Assert.throws(
+ () => root.mixed.fun_param_type_versioned(["hello"]),
+ /Expected boolean instead of "hello"/,
+ "should throw for invalid type"
+ );
+
+ let propObj = { prop_any: "prop_any", prop_mv3: "prop_mv3" };
+ root.mixed.fun_param_change(propObj);
+ wrapper.verify("call", "mixed", "fun_param_change", [propObj]);
+ Assert.throws(
+ () => root.mixed.fun_param_change({ prop_mv2: "prop_mv2", ...propObj }),
+ /Unexpected property "prop_mv2"/,
+ "should throw for versioned type"
+ );
+ wrapper.checkErrors([
+ `Property "prop_mv2" is unsupported in Manifest Version 3`,
+ ]);
+
+ root.mixed.PROP_mv3.sub_foo();
+ wrapper.verify("call", "mixed.PROP_mv3", "sub_foo", []);
+ Assert.throws(
+ () => root.mixed.PROP_mv3.sub_no_match(),
+ /TypeError: root.mixed.PROP_mv3.sub_no_match is not a function/,
+ "sub_no_match should throw"
+ );
+});
+
+add_task(async function test_normalize_V3() {
+ let wrapper = getContextWrapper(3);
+
+ // Test normalize additions to the manifest structure
+ normalizeTest(
+ {
+ versioned_extend: "test",
+ },
+ normalized => {
+ Assert.equal(
+ normalized.error,
+ `Unexpected property "versioned_extend"`,
+ "expected manifest error"
+ );
+ wrapper.checkErrors([
+ `Property "versioned_extend" is unsupported in Manifest Version 3`,
+ ]);
+ },
+ wrapper
+ );
+
+ // Test normalizing baseType
+ normalizeTest(
+ {
+ permissions: ["base"],
+ },
+ normalized => {
+ Assert.equal(
+ normalized.value.permissions[0],
+ "base",
+ "resources normalized"
+ );
+ },
+ wrapper
+ );
+
+ normalizeTest(
+ {
+ permissions: ["extended"],
+ },
+ normalized => {
+ Assert.equal(
+ normalized.value.permissions[0],
+ "extended",
+ "resources normalized"
+ );
+ },
+ wrapper
+ );
+
+ // Test normalizing a value
+ normalizeTest(
+ {
+ multiple_choice: ["foo.html"],
+ },
+ normalized => {
+ Assert.ok(
+ normalized.error.startsWith("Error processing multiple_choice"),
+ "manifest error"
+ );
+ },
+ wrapper
+ );
+
+ normalizeTest(
+ {
+ multiple_choice: [true],
+ },
+ normalized => {
+ Assert.equal(
+ normalized.value.multiple_choice[0],
+ true,
+ "resources normalized"
+ );
+ },
+ wrapper
+ );
+
+ normalizeTest(
+ {
+ multiple_choice: [
+ {
+ value: true,
+ },
+ ],
+ },
+ normalized => {
+ Assert.ok(
+ normalized.value.multiple_choice[0].value,
+ "resources normalized"
+ );
+ },
+ wrapper
+ );
+
+ wrapper.tallied = null;
+
+ normalizeTest(
+ {},
+ normalized => {
+ ok(!normalized.error, "manifest normalized");
+ },
+ wrapper
+ );
+
+ // Tests that object definitions including additionalProperties can
+ // successfully accept objects from another manifest version, while ignoring
+ // the actual value from the non-matching manifest value.
+ normalizeTest(
+ {
+ accepting_unrecognized_props: {
+ mv2_only_prop: "mv2 here",
+ mv3_only_prop: "mv3 here",
+ },
+ },
+ normalized => {
+ equal(normalized.error, undefined, "no normalization error");
+ Assert.deepEqual(
+ normalized.value.accepting_unrecognized_props,
+ {
+ mv3_only_prop: "mv3 here",
+ mv3_only_prop_with_default: "only in MV3",
+ },
+ "Normalized object for MV3, without MV2-specific props"
+ );
+ wrapper.checkErrors([
+ `Property "mv2_only_prop" is unsupported in Manifest Version 3`,
+ ]);
+ },
+ wrapper
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_script_filenames.js b/toolkit/components/extensions/test/xpcshell/test_ext_script_filenames.js
new file mode 100644
index 0000000000..8e2f6c7b0d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_script_filenames.js
@@ -0,0 +1,366 @@
+"use strict";
+
+// There is a rejection emitted when a JS file fails to load. On Android,
+// extensions run on the main process and this rejection causes test failures,
+// which is essentially why we need to allow the rejection below.
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Unable to load script.*content_script/
+);
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+function computeSHA256Hash(text) {
+ const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ hasher.init(Ci.nsICryptoHash.SHA256);
+ hasher.update(
+ text.split("").map(c => c.charCodeAt(0)),
+ text.length
+ );
+ return hasher.finish(true);
+}
+
+// This function represents a dummy content or background script that the test
+// cases below should attempt to load but it shouldn't be loaded because we
+// check the extensions of JavaScript files in `nsJARChannel`.
+function scriptThatShouldNotBeLoaded() {
+ browser.test.fail("this should not be executed");
+}
+
+function scriptThatAlwaysRuns() {
+ browser.test.sendMessage("content-script-loaded");
+}
+
+// We use these variables in combination with `scriptThatAlwaysRuns()` to send a
+// signal to the extension and avoid the page to be closed too soon.
+const alwaysRunsFileName = "always_run.js";
+const alwaysRunsContentScript = {
+ matches: ["<all_urls>"],
+ js: [alwaysRunsFileName],
+ run_at: "document_start",
+};
+
+add_task(async function test_content_script_filename_without_extension() {
+ // Filenames without any extension should not be loaded.
+ let invalidFileName = "content_script";
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ alwaysRunsContentScript,
+ {
+ matches: ["<all_urls>"],
+ js: [invalidFileName],
+ },
+ ],
+ },
+ files: {
+ [invalidFileName]: scriptThatShouldNotBeLoaded,
+ [alwaysRunsFileName]: scriptThatAlwaysRuns,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.awaitMessage("content-script-loaded");
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_content_script_filename_with_invalid_extension() {
+ let validFileName = "content_script.js";
+ let invalidFileName = "content_script.xyz";
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ alwaysRunsContentScript,
+ {
+ matches: ["<all_urls>"],
+ js: [validFileName, invalidFileName],
+ },
+ ],
+ },
+ files: {
+ // This makes sure that, when one of the content scripts fails to load,
+ // none of the content scripts are executed.
+ [validFileName]: scriptThatShouldNotBeLoaded,
+ [invalidFileName]: scriptThatShouldNotBeLoaded,
+ [alwaysRunsFileName]: scriptThatAlwaysRuns,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.awaitMessage("content-script-loaded");
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_bg_script_injects_script_with_invalid_ext() {
+ function backgroundScript() {
+ browser.test.sendMessage("background-script-loaded");
+ }
+
+ let validFileName = "background.js";
+ let invalidFileName = "invalid_background.xyz";
+ let extensionData = {
+ background() {
+ const script = document.createElement("script");
+ script.src = "./invalid_background.xyz";
+ document.head.appendChild(script);
+
+ const validScript = document.createElement("script");
+ validScript.src = "./background.js";
+ document.head.appendChild(validScript);
+ },
+ files: {
+ [invalidFileName]: scriptThatShouldNotBeLoaded,
+ [validFileName]: backgroundScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitMessage("background-script-loaded");
+
+ await extension.unload();
+});
+
+add_task(async function test_background_scripts() {
+ function backgroundScript() {
+ browser.test.sendMessage("background-script-loaded");
+ }
+
+ let validFileName = "background.js";
+ let invalidFileName = "invalid_background.xyz";
+ let extensionData = {
+ manifest: {
+ background: {
+ scripts: [invalidFileName, validFileName],
+ },
+ },
+ files: {
+ [invalidFileName]: scriptThatShouldNotBeLoaded,
+ [validFileName]: backgroundScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitMessage("background-script-loaded");
+
+ await extension.unload();
+});
+
+add_task(async function test_background_page_injects_scripts_inline() {
+ function injectedBackgroundScript() {
+ browser.test.log(
+ "inline script injectedBackgroundScript has been executed"
+ );
+ browser.test.sendMessage("background-script-loaded");
+ }
+
+ let backgroundHtmlPage = "background_page.html";
+ let validFileName = "injected_background.js";
+ let invalidFileName = "invalid_background.xyz";
+
+ let inlineScript = `(${function () {
+ const script = document.createElement("script");
+ script.src = "./invalid_background.xyz";
+ document.head.appendChild(script);
+ const validScript = document.createElement("script");
+ validScript.src = "./injected_background.js";
+ document.head.appendChild(validScript);
+ }})()`;
+
+ const inlineScriptSHA256 = computeSHA256Hash(inlineScript);
+
+ info(
+ `Computed sha256 for the inline script injectedBackgroundScript: ${inlineScriptSHA256}`
+ );
+
+ let extensionData = {
+ manifest: {
+ background: { page: backgroundHtmlPage },
+ content_security_policy: [
+ "script-src",
+ "'self'",
+ `'sha256-${inlineScriptSHA256}'`,
+ ";",
+ ].join(" "),
+ },
+ files: {
+ [invalidFileName]: scriptThatShouldNotBeLoaded,
+ [validFileName]: injectedBackgroundScript,
+ "pre-script.js": () => {
+ window.onsecuritypolicyviolation = evt => {
+ const { violatedDirective, originalPolicy } = evt;
+ browser.test.fail(
+ `Unexpected csp violation: ${JSON.stringify({
+ violatedDirective,
+ originalPolicy,
+ })}`
+ );
+ // Let the test to fail immediately when an unexpected csp violation
+ // prevented the inline script from being executed successfully.
+ browser.test.sendMessage("background-script-loaded");
+ };
+ },
+ [backgroundHtmlPage]: `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8"></head>
+ <script src="pre-script.js"></script>
+ <script>${inlineScript}</script>
+ </head>
+ </html>`,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitMessage("background-script-loaded");
+
+ await extension.unload();
+});
+
+add_task(async function test_background_page_injects_scripts() {
+ // This is the initial background script loaded in the HTML page.
+ function backgroundScript() {
+ const script = document.createElement("script");
+ script.src = "./invalid_background.xyz";
+ document.head.appendChild(script);
+
+ const validScript = document.createElement("script");
+ validScript.src = "./injected_background.js";
+ document.head.appendChild(validScript);
+ }
+
+ // This is the script injected by the script defined in `backgroundScript()`.
+ function injectedBackgroundScript() {
+ browser.test.sendMessage("background-script-loaded");
+ }
+
+ let backgroundHtmlPage = "background_page.html";
+ let validFileName = "injected_background.js";
+ let invalidFileName = "invalid_background.xyz";
+ let extensionData = {
+ manifest: {
+ background: { page: backgroundHtmlPage },
+ },
+ files: {
+ [invalidFileName]: scriptThatShouldNotBeLoaded,
+ [validFileName]: injectedBackgroundScript,
+ [backgroundHtmlPage]: `
+ <html>
+ <head>
+ <meta charset="utf-8"></head>
+ <script src="./background.js"></script>
+ </head>
+ </html>`,
+ "background.js": backgroundScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitMessage("background-script-loaded");
+
+ await extension.unload();
+});
+
+add_task(async function test_background_script_registers_content_script() {
+ let invalidFileName = "content_script";
+ let extensionData = {
+ manifest: {
+ permissions: ["<all_urls>"],
+ },
+ async background() {
+ await browser.contentScripts.register({
+ js: [{ file: "/content_script" }],
+ matches: ["<all_urls>"],
+ });
+ browser.test.sendMessage("background-script-loaded");
+ },
+ files: {
+ [invalidFileName]: scriptThatShouldNotBeLoaded,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.awaitMessage("background-script-loaded");
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_web_accessible_resources() {
+ function contentScript() {
+ const script = document.createElement("script");
+ script.src = browser.runtime.getURL("content_script.css");
+ script.onerror = () => {
+ browser.test.sendMessage("second-content-script-loaded");
+ };
+
+ document.head.appendChild(script);
+ }
+
+ let contentScriptFileName = "content_script.js";
+ let invalidFileName = "content_script.css";
+ let extensionData = {
+ manifest: {
+ web_accessible_resources: [invalidFileName],
+ content_scripts: [
+ alwaysRunsContentScript,
+ {
+ matches: ["<all_urls>"],
+ js: [contentScriptFileName],
+ },
+ ],
+ },
+ files: {
+ [invalidFileName]: scriptThatShouldNotBeLoaded,
+ [contentScriptFileName]: contentScript,
+ [alwaysRunsFileName]: scriptThatAlwaysRuns,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.awaitMessage("content-script-loaded");
+ await extension.awaitMessage("second-content-script-loaded");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts.js
new file mode 100644
index 0000000000..464c6bd31d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts.js
@@ -0,0 +1,412 @@
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell, to use
+// the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+const makeExtension = ({ manifest: manifestProps, ...otherProps }) => {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["scripting"],
+ host_permissions: ["http://localhost/*"],
+ granted_host_permissions: true,
+ ...manifestProps,
+ },
+ temporarilyInstalled: true,
+ ...otherProps,
+ });
+};
+
+add_task(async function test_registerContentScripts_runAt() {
+ let extension = makeExtension({
+ async background() {
+ const TEST_CASES = [
+ {
+ title: "runAt: document_idle",
+ params: [
+ {
+ id: "script-idle",
+ js: ["script-idle.js"],
+ matches: ["http://*/*/file_sample.html"],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ },
+ ],
+ },
+ {
+ title: "no runAt specified",
+ params: [
+ {
+ id: "script-idle-default",
+ js: ["script-idle-default.js"],
+ matches: ["http://*/*/file_sample.html"],
+ // `runAt` defaults to `document_idle`.
+ persistAcrossSessions: false,
+ },
+ ],
+ },
+ {
+ title: "runAt: document_end",
+ params: [
+ {
+ id: "script-end",
+ js: ["script-end.js"],
+ matches: ["http://*/*/file_sample.html"],
+ runAt: "document_end",
+ persistAcrossSessions: false,
+ },
+ ],
+ },
+ {
+ title: "runAt: document_start",
+ params: [
+ {
+ id: "script-start",
+ js: ["script-start.js"],
+ matches: ["http://*/*/file_sample.html"],
+ runAt: "document_start",
+ persistAcrossSessions: false,
+ },
+ ],
+ },
+ ];
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(0, scripts.length, "expected no registered script");
+
+ for (const { title, params } of TEST_CASES) {
+ const res = await browser.scripting.registerContentScripts(params);
+ browser.test.assertEq(undefined, res, `${title} - expected no result`);
+ }
+
+ scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(
+ TEST_CASES.length,
+ scripts.length,
+ `expected ${TEST_CASES.length} registered scripts`
+ );
+ browser.test.assertEq(
+ JSON.stringify([
+ {
+ id: "script-idle",
+ allFrames: false,
+ matches: ["http://*/*/file_sample.html"],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ js: ["script-idle.js"],
+ },
+ {
+ id: "script-idle-default",
+ allFrames: false,
+ matches: ["http://*/*/file_sample.html"],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ js: ["script-idle-default.js"],
+ },
+ {
+ id: "script-end",
+ allFrames: false,
+ matches: ["http://*/*/file_sample.html"],
+ runAt: "document_end",
+ persistAcrossSessions: false,
+ js: ["script-end.js"],
+ },
+ {
+ id: "script-start",
+ allFrames: false,
+ matches: ["http://*/*/file_sample.html"],
+ runAt: "document_start",
+ persistAcrossSessions: false,
+ js: ["script-start.js"],
+ },
+ ]),
+ JSON.stringify(scripts),
+ "got expected scripts"
+ );
+
+ browser.test.sendMessage("background-ready");
+ },
+ files: {
+ "script-start.js": () => {
+ browser.test.assertEq(
+ "loading",
+ document.readyState,
+ "expected state 'loading' at document_start"
+ );
+ browser.test.sendMessage("script-ran", "script-start.js");
+ },
+ "script-end.js": () => {
+ browser.test.assertTrue(
+ ["interactive", "complete"].includes(document.readyState),
+ `expected state 'interactive' or 'complete' at document_end, got: ${document.readyState}`
+ );
+ browser.test.sendMessage("script-ran", "script-end.js");
+ },
+ "script-idle.js": () => {
+ browser.test.assertEq(
+ "complete",
+ document.readyState,
+ "expected state 'complete' at document_idle"
+ );
+ browser.test.sendMessage("script-ran", "script-idle.js");
+ },
+ "script-idle-default.js": () => {
+ browser.test.assertEq(
+ "complete",
+ document.readyState,
+ "expected state 'complete' at document_idle"
+ );
+ browser.test.sendMessage("script-ran", "script-idle-default.js");
+ },
+ },
+ });
+
+ let scriptsRan = [];
+ let completePromise = new Promise(resolve => {
+ extension.onMessage("script-ran", result => {
+ scriptsRan.push(result);
+
+ // The value below should be updated when TEST_CASES above is changed.
+ if (scriptsRan.length === 4) {
+ resolve();
+ }
+ });
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await completePromise;
+
+ Assert.deepEqual(
+ [
+ "script-start.js",
+ "script-end.js",
+ "script-idle.js",
+ "script-idle-default.js",
+ ],
+ scriptsRan,
+ "got expected executed scripts"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_register_and_unregister() {
+ let extension = makeExtension({
+ async background() {
+ const script = {
+ id: "a-script",
+ js: ["script.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: false,
+ };
+
+ let results = await Promise.allSettled([
+ browser.scripting.registerContentScripts([script]),
+ browser.scripting.unregisterContentScripts(),
+ ]);
+
+ browser.test.assertEq(
+ 2,
+ results.filter(result => result.status === "fulfilled").length,
+ "got expected number of fulfilled promises"
+ );
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(0, scripts.length, "expected no registered script");
+
+ browser.test.sendMessage("background-done");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+
+ // Verify that the registered content scripts on the extension are correct.
+ let contentScripts = Array.from(
+ extension.extension.registeredContentScripts.values()
+ );
+ Assert.equal(0, contentScripts.length, "expected no registered scripts");
+
+ await extension.unload();
+});
+
+add_task(async function test_register_and_unregister_multiple_times() {
+ let extension = makeExtension({
+ async background() {
+ // We use the same script `id` on purpose in this test.
+ let results = await Promise.allSettled([
+ browser.scripting.registerContentScripts([
+ {
+ id: "a-script",
+ js: ["script-1.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: false,
+ },
+ ]),
+ browser.scripting.unregisterContentScripts(),
+ browser.scripting.registerContentScripts([
+ {
+ id: "a-script",
+ js: ["script-2.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: false,
+ },
+ ]),
+ browser.scripting.unregisterContentScripts(),
+ browser.scripting.registerContentScripts([
+ {
+ id: "a-script",
+ js: ["script-3.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: false,
+ },
+ ]),
+ ]);
+
+ browser.test.assertEq(
+ 5,
+ results.filter(result => result.status === "fulfilled").length,
+ "got expected number of fulfilled promises"
+ );
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+
+ browser.test.sendMessage("background-done");
+ },
+ files: {
+ "script-1.js": "",
+ "script-2.js": "",
+ "script-3.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+
+ // Verify that the registered content scripts on the extension are correct.
+ let contentScripts = Array.from(
+ extension.extension.registeredContentScripts.values()
+ );
+ Assert.equal(1, contentScripts.length, "expected 1 registered script");
+ Assert.ok(
+ contentScripts[0].jsPaths[0].endsWith("script-3.js"),
+ "got expected js file"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_register_update_and_unregister() {
+ let extension = makeExtension({
+ async background() {
+ const script = {
+ id: "a-script",
+ js: ["script-1.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: false,
+ };
+ const updatedScript1 = { ...script, js: ["script-2.js"] };
+ const updatedScript2 = { ...script, js: ["script-3.js"] };
+
+ let results = await Promise.allSettled([
+ browser.scripting.registerContentScripts([script]),
+ browser.scripting.updateContentScripts([updatedScript1]),
+ browser.scripting.updateContentScripts([updatedScript2]),
+ browser.scripting.getRegisteredContentScripts(),
+ browser.scripting.unregisterContentScripts(),
+ browser.scripting.updateContentScripts([script]),
+ ]);
+
+ browser.test.assertEq(6, results.length, "expected 6 results");
+ browser.test.assertEq(
+ "fulfilled",
+ results[0].status,
+ "expected fulfilled promise (registeredContentScripts)"
+ );
+ browser.test.assertEq(
+ "fulfilled",
+ results[1].status,
+ "expected fulfilled promise (updateContentScripts)"
+ );
+ browser.test.assertEq(
+ "fulfilled",
+ results[2].status,
+ "expected fulfilled promise (updateContentScripts)"
+ );
+ browser.test.assertEq(
+ "fulfilled",
+ results[3].status,
+ "expected fulfilled promise (getRegisteredContentScripts)"
+ );
+ browser.test.assertEq(
+ JSON.stringify([
+ {
+ id: "a-script",
+ allFrames: false,
+ matches: ["http://*/*/file_sample.html"],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ js: ["script-3.js"],
+ },
+ ]),
+ JSON.stringify(results[3].value),
+ "expected updated content script"
+ );
+ browser.test.assertEq(
+ "fulfilled",
+ results[4].status,
+ "expected fulfilled promise (unregisterContentScripts)"
+ );
+ browser.test.assertEq(
+ "rejected",
+ results[5].status,
+ "expected rejected promise because script should have been unregistered"
+ );
+ browser.test.assertEq(
+ `Content script with id "${script.id}" does not exist.`,
+ results[5].reason.message,
+ "expected error message about script not found"
+ );
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(0, scripts.length, "expected no registered script");
+
+ browser.test.sendMessage("background-done");
+ },
+ files: {
+ "script-1.js": "",
+ "script-2.js": "",
+ "script-3.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+
+ // Verify that the registered content scripts on the extension are correct.
+ let contentScripts = Array.from(
+ extension.extension.registeredContentScripts.values()
+ );
+ Assert.equal(0, contentScripts.length, "expected no registered scripts");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_css.js
new file mode 100644
index 0000000000..21190d2d59
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_css.js
@@ -0,0 +1,331 @@
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell, to use
+// the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+const makeExtension = ({ manifest: manifestProps, ...otherProps }) => {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["scripting"],
+ host_permissions: ["http://localhost/*"],
+ granted_host_permissions: true,
+ ...manifestProps,
+ },
+ allowInsecureRequests: true,
+ temporarilyInstalled: true,
+ ...otherProps,
+ });
+};
+
+add_task(async function test_registerContentScripts_css() {
+ let extension = makeExtension({
+ async background() {
+ // This script is injected in all frames after the styles so that we can
+ // verify the registered styles.
+ const checkAppliedStyleScript = {
+ id: "check-applied-styles",
+ allFrames: true,
+ matches: ["http://*/*/*.html"],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ js: ["check-applied-styles.js"],
+ };
+
+ // Listen to the `load-test-case` message and unregister/register new
+ // content scripts.
+ browser.test.onMessage.addListener(async (msg, data) => {
+ switch (msg) {
+ case "load-test-case":
+ const { title, params, skipCheckScriptRegistration } = data;
+ const expectedScripts = [];
+
+ await browser.scripting.unregisterContentScripts();
+
+ if (!skipCheckScriptRegistration) {
+ await browser.scripting.registerContentScripts([
+ checkAppliedStyleScript,
+ ]);
+
+ expectedScripts.push(checkAppliedStyleScript);
+ }
+
+ expectedScripts.push(...params);
+
+ const res = await browser.scripting.registerContentScripts(params);
+ browser.test.assertEq(
+ res,
+ undefined,
+ `${title} - expected no result`
+ );
+ const scripts =
+ await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(
+ expectedScripts.length,
+ scripts.length,
+ `${title} - expected ${expectedScripts.length} registered scripts`
+ );
+ browser.test.assertEq(
+ JSON.stringify(expectedScripts),
+ JSON.stringify(scripts),
+ `${title} - got expected registered scripts`
+ );
+
+ browser.test.sendMessage(`${msg}-done`);
+ break;
+ default:
+ browser.test.fail(`received unexpected message: ${msg}`);
+ }
+ });
+
+ browser.test.sendMessage("background-ready");
+ },
+ files: {
+ "check-applied-styles.js": () => {
+ browser.test.sendMessage(
+ `background-color-${location.pathname.split("/").pop()}`,
+ getComputedStyle(document.querySelector("#test")).backgroundColor
+ );
+ },
+ "style-1.css": "#test { background-color: rgb(255, 0, 0); }",
+ "style-2.css": "#test { background-color: rgb(0, 0, 255); }",
+ "style-3.css": "html { background-color: rgb(0, 255, 0); }",
+ "script-document-start.js": async () => {
+ const testElement = document.querySelector("html");
+
+ browser.test.assertEq(
+ "rgb(0, 255, 0)",
+ getComputedStyle(testElement).backgroundColor,
+ "got expected style in script-document-start.js"
+ );
+
+ testElement.style.backgroundColor = "rgb(4, 4, 4)";
+ },
+ "check-applied-styles-document-start.js": () => {
+ browser.test.sendMessage(
+ `background-color-${location.pathname.split("/").pop()}`,
+ getComputedStyle(document.querySelector("html")).backgroundColor
+ );
+ },
+ "script-document-end-and-idle.js": () => {
+ const testElement = document.querySelector("#test");
+
+ browser.test.assertEq(
+ "rgb(255, 0, 0)",
+ getComputedStyle(testElement).backgroundColor,
+ "got expected style in script-document-end-and-idle.js"
+ );
+
+ testElement.style.backgroundColor = "rgb(5, 5, 5)";
+ },
+ },
+ });
+
+ const TEST_CASES = [
+ {
+ title: "a css file",
+ params: [
+ {
+ id: "style-1",
+ allFrames: false,
+ matches: ["http://*/*/*.html"],
+ // TODO: Bug 1759117 - runAt should not affect css injection
+ runAt: "document_start",
+ persistAcrossSessions: false,
+ css: ["style-1.css"],
+ },
+ ],
+ expected: ["rgb(255, 0, 0)", "rgba(0, 0, 0, 0)"],
+ },
+ {
+ title: "css and allFrames: true",
+ params: [
+ {
+ id: "style-1",
+ allFrames: true,
+ matches: ["http://*/*/*.html"],
+ // TODO: Bug 1759117 - runAt should not affect css injection
+ runAt: "document_start",
+ persistAcrossSessions: false,
+ css: ["style-1.css"],
+ },
+ ],
+ expected: ["rgb(255, 0, 0)", "rgb(255, 0, 0)"],
+ },
+ {
+ title: "css and allFrames: true but matches restricted to top frame",
+ params: [
+ {
+ id: "style-1",
+ allFrames: true,
+ matches: ["http://*/*/file_with_iframe.html"],
+ // TODO: Bug 1759117 - runAt should not affect css injection
+ runAt: "document_start",
+ persistAcrossSessions: false,
+ css: ["style-1.css"],
+ },
+ ],
+ expected: ["rgb(255, 0, 0)", "rgba(0, 0, 0, 0)"],
+ },
+ {
+ title: "css and excludeMatches set",
+ params: [
+ {
+ id: "style-1",
+ allFrames: true,
+ matches: ["http://*/*/*.html"],
+ // TODO: Bug 1759117 - runAt should not affect css injection
+ runAt: "document_start",
+ persistAcrossSessions: false,
+ css: ["style-1.css"],
+ excludeMatches: ["http://*/*/file_with_iframe.html"],
+ },
+ ],
+ expected: ["rgba(0, 0, 0, 0)", "rgb(255, 0, 0)"],
+ },
+ {
+ title: "two css files",
+ params: [
+ {
+ id: "style-1-and-2",
+ allFrames: false,
+ matches: ["http://*/*/*.html"],
+ // TODO: Bug 1759117 - runAt should not affect css injection
+ runAt: "document_start",
+ persistAcrossSessions: false,
+ css: ["style-1.css", "style-2.css"],
+ },
+ ],
+ expected: ["rgb(0, 0, 255)", "rgba(0, 0, 0, 0)"],
+ },
+ {
+ title: "two scripts with css",
+ params: [
+ {
+ id: "style-1",
+ allFrames: false,
+ matches: ["http://*/*/*.html"],
+ runAt: "document_end",
+ persistAcrossSessions: false,
+ css: ["style-1.css"],
+ },
+ {
+ id: "style-2",
+ allFrames: false,
+ matches: ["http://*/*/*.html"],
+ runAt: "document_start",
+ persistAcrossSessions: false,
+ css: ["style-2.css"],
+ },
+ ],
+ // TODO: Bug 1759117 - The expected value should be `rgb(0, 0, 255)`
+ // because runAt should not affect css injection and therefore the two
+ // styles should be applied one after the other.
+ expected: ["rgb(255, 0, 0)", "rgba(0, 0, 0, 0)"],
+ },
+ {
+ title: "js and css with runAt: document_start",
+ params: [
+ {
+ id: "js-and-style-start",
+ allFrames: true,
+ matches: ["http://*/*/*.html"],
+ runAt: "document_start",
+ persistAcrossSessions: false,
+ css: ["style-3.css"],
+ // Inject the check script last to be able to send a message back to
+ // the test case. This works with `skipCheckScriptRegistration: true`
+ // below.
+ js: [
+ "script-document-start.js",
+ "check-applied-styles-document-start.js",
+ ],
+ },
+ ],
+ expected: ["rgb(4, 4, 4)", "rgb(4, 4, 4)"],
+ skipCheckScriptRegistration: true,
+ },
+ {
+ title: "js and css with runAt: document_end",
+ params: [
+ {
+ id: "js-and-style-end",
+ allFrames: true,
+ matches: ["http://*/*/*.html"],
+ runAt: "document_end",
+ persistAcrossSessions: false,
+ css: ["style-1.css"],
+ // Inject the check script last to be able to send a message back to
+ // the test case. This works with `skipCheckScriptRegistration: true`
+ // below.
+ js: ["script-document-end-and-idle.js", "check-applied-styles.js"],
+ },
+ ],
+ expected: ["rgb(5, 5, 5)", "rgb(5, 5, 5)"],
+ skipCheckScriptRegistration: true,
+ },
+ {
+ title: "js and css with runAt: document_idle",
+ params: [
+ {
+ id: "js-and-style-idle",
+ allFrames: true,
+ matches: ["http://*/*/*.html"],
+ runAt: "document_idle",
+ persistAcrossSessions: false,
+ css: ["style-1.css"],
+ // Inject the check script last to be able to send a message back to
+ // the test case. This works with `skipCheckScriptRegistration: true`
+ // below.
+ js: ["script-document-end-and-idle.js", "check-applied-styles.js"],
+ },
+ ],
+ expected: ["rgb(5, 5, 5)", "rgb(5, 5, 5)"],
+ skipCheckScriptRegistration: true,
+ },
+ ];
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ for (const {
+ title,
+ params,
+ expected,
+ skipCheckScriptRegistration,
+ } of TEST_CASES) {
+ extension.sendMessage("load-test-case", {
+ title,
+ params,
+ skipCheckScriptRegistration,
+ });
+ await extension.awaitMessage("load-test-case-done");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_with_iframe.html`
+ );
+
+ const backgroundColors = [
+ await extension.awaitMessage("background-color-file_with_iframe.html"),
+ await extension.awaitMessage("background-color-file_sample.html"),
+ ];
+
+ Assert.deepEqual(
+ expected,
+ backgroundColors,
+ `${title} - got expected colors`
+ );
+
+ await contentPage.close();
+ }
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_file.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_file.js
new file mode 100644
index 0000000000..3c806439ce
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_file.js
@@ -0,0 +1,77 @@
+"use strict";
+
+const FILE_DUMMY_URL = Services.io.newFileURI(
+ do_get_file("data/dummy_page.html")
+).spec;
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell, to use
+// the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+const makeExtension = ({ manifest: manifestProps, ...otherProps }) => {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["scripting"],
+ host_permissions: ["<all_urls>"],
+ granted_host_permissions: true,
+ ...manifestProps,
+ },
+ temporarilyInstalled: true,
+ ...otherProps,
+ });
+};
+
+add_task(async function test_registered_content_script_with_files() {
+ let extension = makeExtension({
+ async background() {
+ const MATCHES = [
+ { id: "script-1", matches: ["<all_urls>"] },
+ { id: "script-2", matches: ["file:///*"] },
+ { id: "script-3", matches: ["file://*/*dummy_page.html"] },
+ { id: "fail-if-executed", matches: ["*://*/*"] },
+ ];
+
+ await browser.scripting.registerContentScripts(
+ MATCHES.map(({ id, matches }) => ({
+ id,
+ js: [`${id}.js`],
+ matches,
+ persistAcrossSessions: false,
+ }))
+ );
+
+ browser.test.sendMessage("background-ready");
+ },
+ files: {
+ "script-1.js": () => {
+ browser.test.sendMessage("script-1-ran");
+ },
+ "script-2.js": () => {
+ browser.test.sendMessage("script-2-ran");
+ },
+ "script-3.js": () => {
+ browser.test.sendMessage("script-3-ran");
+ },
+ "fail-if-executed.js": () => {
+ browser.test.fail("this script should not be executed");
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(FILE_DUMMY_URL);
+
+ await Promise.all([
+ extension.awaitMessage("script-1-ran"),
+ extension.awaitMessage("script-2-ran"),
+ extension.awaitMessage("script-3-ran"),
+ ]);
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_mv2.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_mv2.js
new file mode 100644
index 0000000000..53fb77c4da
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_mv2.js
@@ -0,0 +1,23 @@
+"use strict";
+
+add_task(async function test_scripting_enabled_in_mv2() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 2,
+ permissions: ["scripting"],
+ },
+ background() {
+ browser.test.assertEq(
+ "object",
+ typeof browser.scripting,
+ "expected scripting namespace to be defined"
+ );
+
+ browser.test.sendMessage("background-done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_persistAcrossSessions.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_persistAcrossSessions.js
new file mode 100644
index 0000000000..cae09b5d2e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_persistAcrossSessions.js
@@ -0,0 +1,760 @@
+"use strict";
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+const { ExtensionScriptingStore } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionScriptingStore.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+const makeExtension = ({ manifest: manifestProps, ...otherProps }) => {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 2,
+ permissions: ["scripting"],
+ ...manifestProps,
+ },
+ useAddonManager: "permanent",
+ ...otherProps,
+ });
+};
+
+const assertNumScriptsInStore = async (extension, expectedNum) => {
+ // `registerContentScripts`/`updateContentScripts()`/`unregisterContentScripts`
+ // call `ExtensionScriptingStore.persistAll()` without awaiting it, which
+ // isn't a problem in practice but this becomes a problem in this test given
+ // that we should make sure the startup cache is updated before checking it.
+ await TestUtils.waitForCondition(async () => {
+ let scripts =
+ await ExtensionScriptingStore._getStoreForTesting().getByExtensionId(
+ extension.id
+ );
+ return scripts.length === expectedNum;
+ }, "wait until the store is updated with the expected number of scripts");
+
+ let scripts =
+ await ExtensionScriptingStore._getStoreForTesting().getByExtensionId(
+ extension.id
+ );
+ Assert.equal(
+ scripts.length,
+ expectedNum,
+ `expected ${expectedNum} script in store`
+ );
+};
+
+const verifyRegisterContentScripts = async manifestVersion => {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = makeExtension({
+ manifest: {
+ manifest_version: manifestVersion,
+ },
+ async background() {
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+
+ // Only register the content script if it wasn't registered before. Since
+ // there is only one script, we don't check its ID.
+ if (!scripts.length) {
+ const script = {
+ id: "script",
+ js: ["script.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: true,
+ };
+
+ await browser.scripting.registerContentScripts([script]);
+ browser.test.sendMessage("script-registered");
+ return;
+ }
+
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+ browser.test.sendMessage("script-already-registered");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("script-registered");
+ await assertNumScriptsInStore(extension, 1);
+
+ await AddonTestUtils.promiseRestartManager();
+ await assertNumScriptsInStore(extension, 1);
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("script-already-registered");
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+ await assertNumScriptsInStore(extension, 0);
+};
+
+add_task(async function test_registerContentScripts_mv2() {
+ await verifyRegisterContentScripts(2);
+});
+
+add_task(async function test_registerContentScripts_mv3() {
+ await verifyRegisterContentScripts(3);
+});
+
+const verifyUpdateContentScripts = async manifestVersion => {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = makeExtension({
+ manifest: {
+ manifest_version: manifestVersion,
+ },
+ async background() {
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+
+ // Only register the content script if it wasn't registered before. Since
+ // there is only one script, we don't check its ID.
+ if (!scripts.length) {
+ const script = {
+ id: "script",
+ js: ["script.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: true,
+ };
+
+ await browser.scripting.registerContentScripts([script]);
+ browser.test.sendMessage("script-registered");
+ return;
+ }
+
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+ await browser.scripting.updateContentScripts([
+ { id: scripts[0].id, persistAcrossSessions: false },
+ ]);
+ browser.test.sendMessage("script-updated");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("script-registered");
+ await assertNumScriptsInStore(extension, 1);
+
+ // Simulate a new session.
+ await AddonTestUtils.promiseRestartManager();
+ await assertNumScriptsInStore(extension, 1);
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("script-updated");
+ await assertNumScriptsInStore(extension, 0);
+
+ // Simulate another new session.
+ await AddonTestUtils.promiseRestartManager();
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("script-registered");
+ await assertNumScriptsInStore(extension, 1);
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+ await assertNumScriptsInStore(extension, 0);
+};
+
+add_task(async function test_updateContentScripts() {
+ await verifyUpdateContentScripts(2);
+});
+
+add_task(async function test_updateContentScripts_mv3() {
+ await verifyUpdateContentScripts(3);
+});
+
+const verifyUnregisterContentScripts = async manifestVersion => {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = makeExtension({
+ manifest: {
+ manifest_version: manifestVersion,
+ },
+ async background() {
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+
+ // Only register the content script if it wasn't registered before. Since
+ // there is only one script, we don't check its ID.
+ if (!scripts.length) {
+ const script = {
+ id: "script",
+ js: ["script.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: true,
+ };
+
+ await browser.scripting.registerContentScripts([script]);
+ browser.test.sendMessage("script-registered");
+ return;
+ }
+
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+ await browser.scripting.unregisterContentScripts();
+ browser.test.sendMessage("script-unregistered");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("script-registered");
+ await assertNumScriptsInStore(extension, 1);
+
+ // Simulate a new session.
+ await AddonTestUtils.promiseRestartManager();
+
+ // Script should be still persisted...
+ await assertNumScriptsInStore(extension, 1);
+ await extension.awaitStartup();
+ // ...and we should now enter the second branch of the background script.
+ await extension.awaitMessage("script-unregistered");
+ await assertNumScriptsInStore(extension, 0);
+
+ // Simulate another new session.
+ await AddonTestUtils.promiseRestartManager();
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("script-registered");
+ await assertNumScriptsInStore(extension, 1);
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+ await assertNumScriptsInStore(extension, 0);
+};
+
+add_task(async function test_unregisterContentScripts() {
+ await verifyUnregisterContentScripts(2);
+});
+
+add_task(async function test_unregisterContentScripts_mv3() {
+ await verifyUnregisterContentScripts(3);
+});
+
+add_task(async function test_reload_extension() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = makeExtension({
+ async background() {
+ browser.test.onMessage.addListener(msg => {
+ browser.test.assertEq("reload-extension", msg, `expected msg: ${msg}`);
+ browser.runtime.reload();
+ });
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+
+ // Only register the content script if it wasn't registered before. Since
+ // there is only one script, we don't check its ID.
+ if (!scripts.length) {
+ const script = {
+ id: "script",
+ js: ["script.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: true,
+ };
+
+ await browser.scripting.registerContentScripts([script]);
+ browser.test.sendMessage("script-registered");
+ return;
+ }
+
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+ browser.test.sendMessage("script-already-registered");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("script-registered");
+ await assertNumScriptsInStore(extension, 1);
+
+ extension.sendMessage("reload-extension");
+ // Wait for extension to restart, to make sure reloads works.
+ await AddonTestUtils.promiseWebExtensionStartup(extension.id);
+ await extension.awaitMessage("script-already-registered");
+ await assertNumScriptsInStore(extension, 1);
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+ await assertNumScriptsInStore(extension, 0);
+});
+
+add_task(async function test_disable_and_reenable_extension() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = makeExtension({
+ async background() {
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+
+ // Only register the content script if it wasn't registered before. Since
+ // there is only one script, we don't check its ID.
+ if (!scripts.length) {
+ const script = {
+ id: "script",
+ js: ["script.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: true,
+ };
+
+ await browser.scripting.registerContentScripts([script]);
+ browser.test.sendMessage("script-registered");
+ return;
+ }
+
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+ browser.test.sendMessage("script-already-registered");
+ },
+ files: {
+ "script.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("script-registered");
+ await assertNumScriptsInStore(extension, 1);
+
+ // Disable...
+ await extension.addon.disable();
+ // then re-enable the extension.
+ await extension.addon.enable();
+
+ await extension.awaitMessage("script-already-registered");
+ await assertNumScriptsInStore(extension, 1);
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+ await assertNumScriptsInStore(extension, 0);
+});
+
+add_task(async function test_updateContentScripts_persistAcrossSessions_true() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = makeExtension({
+ async background() {
+ const script = {
+ id: "script",
+ js: ["script-1.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: false,
+ };
+
+ const scripts = await browser.scripting.getRegisteredContentScripts();
+
+ browser.test.onMessage.addListener(async msg => {
+ switch (msg) {
+ case "persist-script":
+ await browser.scripting.updateContentScripts([
+ { id: script.id, persistAcrossSessions: true },
+ ]);
+ browser.test.sendMessage(`${msg}-done`);
+ break;
+
+ case "add-new-js":
+ await browser.scripting.updateContentScripts([
+ { id: script.id, js: ["script-1.js", "script-2.js"] },
+ ]);
+ browser.test.sendMessage(`${msg}-done`);
+ break;
+
+ case "verify-script":
+ // We expect a single registered script, which is the one declared
+ // above but at this point we should have 2 JS files and the
+ // `persistAcrossSessions` option set to `true`.
+ browser.test.assertEq(
+ JSON.stringify([
+ {
+ id: script.id,
+ allFrames: false,
+ matches: script.matches,
+ runAt: "document_idle",
+ persistAcrossSessions: true,
+ js: ["script-1.js", "script-2.js"],
+ },
+ ]),
+ JSON.stringify(scripts),
+ "expected scripts"
+ );
+ browser.test.sendMessage(`${msg}-done`);
+ break;
+
+ default:
+ browser.test.fail(`unexpected message: ${msg}`);
+ }
+ });
+
+ // Only register the content script if it wasn't registered before. Since
+ // there is only one script, we don't check its ID.
+ if (!scripts.length) {
+ await browser.scripting.registerContentScripts([script]);
+ browser.test.sendMessage("script-registered");
+ } else {
+ browser.test.sendMessage("script-already-registered");
+ }
+ },
+ files: {
+ "script-1.js": "",
+ "script-2.js": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("script-registered");
+ await assertNumScriptsInStore(extension, 0);
+
+ // Simulate a new session.
+ await AddonTestUtils.promiseRestartManager();
+ await assertNumScriptsInStore(extension, 0);
+
+ // We expect the script to be registered again because it isn't persisted.
+ await extension.awaitStartup();
+ await extension.awaitMessage("script-registered");
+ await assertNumScriptsInStore(extension, 0);
+
+ // We now tell the background script to update the script to persist it
+ // across sessions.
+ extension.sendMessage("persist-script");
+ await extension.awaitMessage("persist-script-done");
+
+ // Simulate another new session. We expect the content script to be already
+ // registered since it was persisted in the previous (simulated) session.
+ await AddonTestUtils.promiseRestartManager();
+ await assertNumScriptsInStore(extension, 1);
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("script-already-registered");
+ await assertNumScriptsInStore(extension, 1);
+
+ // We tell the background script to update the content script with a new JS
+ // file and we don't change the `persistAcrossSessions` option.
+ extension.sendMessage("add-new-js");
+ await extension.awaitMessage("add-new-js-done");
+
+ // Simulate another new session. We expect the content script to have 2 JS
+ // files and to be registered since it was persisted in the previous
+ // (simulated) session and we didn't update the option.
+ await AddonTestUtils.promiseRestartManager();
+ await assertNumScriptsInStore(extension, 1);
+
+ await extension.awaitStartup();
+ await extension.awaitMessage("script-already-registered");
+ await assertNumScriptsInStore(extension, 1);
+
+ // Let's verify that the script fetched by the background script is the one
+ // we expect at this point: it should have two JS files.
+ extension.sendMessage("verify-script");
+ await extension.awaitMessage("verify-script-done");
+
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+ await assertNumScriptsInStore(extension, 0);
+});
+
+add_task(async function test_multiple_extensions_and_scripts() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension1 = makeExtension({
+ async background() {
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+
+ if (!scripts.length) {
+ await browser.scripting.registerContentScripts([
+ {
+ id: "0",
+ js: ["script-1.js"],
+ matches: ["http://*/*/file_sample.html"],
+ // We should persist this script by default.
+ },
+ {
+ id: "/",
+ js: ["script-2.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: true,
+ },
+ {
+ id: "3",
+ js: ["script-3.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: false,
+ },
+ ]);
+ browser.test.sendMessage("scripts-registered");
+ return;
+ }
+
+ browser.test.assertEq(2, scripts.length, "expected 2 registered scripts");
+ browser.test.sendMessage("scripts-already-registered");
+ },
+ files: {
+ "script-1.js": "",
+ "script-2.js": "",
+ "script-3.js": "",
+ },
+ });
+
+ let extension2 = makeExtension({
+ async background() {
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+
+ if (!scripts.length) {
+ await browser.scripting.registerContentScripts([
+ {
+ id: "1",
+ js: ["script-1.js"],
+ matches: ["http://*/*/file_sample.html"],
+ // We should persist this script by default.
+ },
+ {
+ id: "2",
+ js: ["script-2.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: false,
+ },
+ {
+ id: "\uFFFD 🍕 Boö",
+ js: ["script-3.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: true,
+ },
+ ]);
+ browser.test.sendMessage("scripts-registered");
+ return;
+ }
+
+ browser.test.assertEq(2, scripts.length, "expected 2 registered scripts");
+ browser.test.assertEq(
+ JSON.stringify(["script-1.js"]),
+ JSON.stringify(scripts[0].js),
+ "expected a single 'script-1.js' js file"
+ );
+ browser.test.assertEq(
+ "\uFFFD 🍕 Boö",
+ scripts[1].id,
+ "expected correct ID"
+ );
+ browser.test.sendMessage("scripts-already-registered");
+ },
+ files: {
+ "script-1.js": "",
+ "script-2.js": "",
+ "script-3.js": "",
+ },
+ });
+
+ await Promise.all([extension1.startup(), extension2.startup()]);
+
+ await Promise.all([
+ extension1.awaitMessage("scripts-registered"),
+ extension2.awaitMessage("scripts-registered"),
+ ]);
+ await assertNumScriptsInStore(extension1, 2);
+ await assertNumScriptsInStore(extension2, 2);
+
+ await AddonTestUtils.promiseRestartManager();
+ await assertNumScriptsInStore(extension1, 2);
+ await assertNumScriptsInStore(extension2, 2);
+
+ await Promise.all([extension1.awaitStartup(), extension2.awaitStartup()]);
+ await Promise.all([
+ extension1.awaitMessage("scripts-already-registered"),
+ extension2.awaitMessage("scripts-already-registered"),
+ ]);
+
+ await Promise.all([extension1.unload(), extension2.unload()]);
+ await AddonTestUtils.promiseShutdownManager();
+ await assertNumScriptsInStore(extension1, 0);
+ await assertNumScriptsInStore(extension2, 0);
+});
+
+add_task(async function test_persisted_scripts_cleared_on_addon_updates() {
+ await AddonTestUtils.promiseStartupManager();
+
+ function background() {
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ switch (msg) {
+ case "registerContentScripts":
+ await browser.scripting.registerContentScripts(...args);
+ break;
+ case "unregisterContentScripts":
+ await browser.scripting.unregisterContentScripts(...args);
+ break;
+ default:
+ browser.test.fail(`Unexpected test message: ${msg}`);
+ }
+ browser.test.sendMessage(`${msg}:done`);
+ });
+ }
+
+ async function registerContentScript(ext, scriptFileName) {
+ ext.sendMessage("registerContentScripts", [
+ {
+ id: scriptFileName,
+ js: [scriptFileName],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: true,
+ },
+ ]);
+ await ext.awaitMessage("registerContentScripts:done");
+ }
+
+ let extension1Data = {
+ manifest: {
+ manifest_version: 2,
+ permissions: ["scripting"],
+ version: "1.0",
+ browser_specific_settings: {
+ // Set an explicit extension id so that extension.upgrade
+ // will trigger the extension to be started with the expected
+ // "ADDON_UPGRADE" / "ADDON_DOWNGRADE" extension.startupReason.
+ gecko: { id: "extension1@mochi.test" },
+ },
+ },
+ useAddonManager: "permanent",
+ background,
+ files: {
+ "script-1.js": "",
+ },
+ };
+
+ let extension1 = ExtensionTestUtils.loadExtension(extension1Data);
+
+ let extension2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 2,
+ permissions: ["scripting"],
+ browser_specific_settings: {
+ gecko: { id: "extension2@mochi.test" },
+ },
+ },
+ useAddonManager: "permanent",
+ background,
+ files: {
+ "script-2.js": "",
+ },
+ });
+
+ await extension1.startup();
+ await assertNumScriptsInStore(extension1, 0);
+ await assertIsPersistentScriptsCachedFlag(extension1, false);
+
+ await extension2.startup();
+ await assertNumScriptsInStore(extension2, 0);
+ await assertIsPersistentScriptsCachedFlag(extension2, false);
+
+ await registerContentScript(extension1, "script-1.js");
+ await assertNumScriptsInStore(extension1, 1);
+ await assertIsPersistentScriptsCachedFlag(extension1, true);
+
+ await registerContentScript(extension2, "script-2.js");
+ await assertNumScriptsInStore(extension2, 1);
+ await assertIsPersistentScriptsCachedFlag(extension2, true);
+
+ info("Verify that scripts are still registered on a browser startup");
+ await AddonTestUtils.promiseRestartManager();
+ await extension1.awaitStartup();
+ await extension2.awaitStartup();
+ equal(
+ extension1.extension.startupReason,
+ "APP_STARTUP",
+ "Got the expected startupReason on AOM restart"
+ );
+
+ await assertNumScriptsInStore(extension1, 1);
+ await assertIsPersistentScriptsCachedFlag(extension1, true);
+ await assertNumScriptsInStore(extension2, 1);
+ await assertIsPersistentScriptsCachedFlag(extension2, true);
+
+ async function testOnAddonUpdates(
+ extensionUpdateData,
+ expectedStartupReason
+ ) {
+ await extension1.upgrade(extensionUpdateData);
+ equal(
+ extension1.extension.startupReason,
+ expectedStartupReason,
+ "Got the expected startupReason on upgrade"
+ );
+
+ await assertNumScriptsInStore(extension1, 0);
+ await assertIsPersistentScriptsCachedFlag(extension1, false);
+ await assertNumScriptsInStore(extension2, 1);
+ await assertIsPersistentScriptsCachedFlag(extension2, true);
+ }
+
+ info("Verify that scripts are cleared on upgrade");
+ await testOnAddonUpdates(
+ {
+ ...extension1Data,
+ manifest: {
+ ...extension1Data.manifest,
+ version: "2.0",
+ },
+ },
+ "ADDON_UPGRADE"
+ );
+
+ await registerContentScript(extension1, "script-1.js");
+ await assertNumScriptsInStore(extension1, 1);
+
+ info("Verify that scripts are cleared on downgrade");
+ await testOnAddonUpdates(extension1Data, "ADDON_DOWNGRADE");
+
+ await registerContentScript(extension1, "script-1.js");
+ await assertNumScriptsInStore(extension1, 1);
+
+ info("Verify that scripts are cleared on upgrade to same version");
+ await testOnAddonUpdates(extension1Data, "ADDON_UPGRADE");
+
+ await extension1.unload();
+ await extension2.unload();
+
+ await assertNumScriptsInStore(extension1, 0);
+ await assertIsPersistentScriptsCachedFlag(extension1, undefined);
+ await assertNumScriptsInStore(extension2, 0);
+ await assertIsPersistentScriptsCachedFlag(extension2, undefined);
+
+ info("Verify stale persisted scripts cleared on re-install");
+ // Inject a stale persisted script into the store.
+ await ExtensionScriptingStore._getStoreForTesting().writeMany(extension1.id, [
+ {
+ id: "script-1.js",
+ allFrames: false,
+ matches: ["http://*/*/file_sample.html"],
+ runAt: "document_idle",
+ persistAcrossSessions: true,
+ js: ["script-1.js"],
+ },
+ ]);
+ await assertNumScriptsInStore(extension1, 1);
+ const extension1Reinstalled =
+ ExtensionTestUtils.loadExtension(extension1Data);
+ await extension1Reinstalled.startup();
+ equal(
+ extension1Reinstalled.extension.startupReason,
+ "ADDON_INSTALL",
+ "Got the expected startupReason on re-install"
+ );
+ await assertNumScriptsInStore(extension1Reinstalled, 0);
+ await assertIsPersistentScriptsCachedFlag(extension1Reinstalled, false);
+ await extension1Reinstalled.unload();
+ await assertNumScriptsInStore(extension1Reinstalled, 0);
+ await assertIsPersistentScriptsCachedFlag(extension1Reinstalled, undefined);
+
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_startupCache.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_startupCache.js
new file mode 100644
index 0000000000..07445dafbe
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_startupCache.js
@@ -0,0 +1,167 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+const { ExtensionScriptingStore } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionScriptingStore.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+add_task(async function test_hasPersistedScripts_startup_cache() {
+ let extension1 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 2,
+ permissions: ["scripting"],
+ },
+ // Set the startup reason to "APP_STARTUP", used to be able to simulate
+ // the behavior expected on calls to `ExtensionScriptingStore.init(extension)`
+ // when the addon has not been just installed, but it is being loaded as part
+ // of the browser application starting up.
+ startupReason: "APP_STARTUP",
+ background() {
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ switch (msg) {
+ case "registerContentScripts":
+ await browser.scripting.registerContentScripts(...args);
+ break;
+ case "unregisterContentScripts":
+ await browser.scripting.unregisterContentScripts(...args);
+ break;
+ default:
+ browser.test.fail(`Unexpected test message: ${msg}`);
+ }
+ browser.test.sendMessage(`${msg}:done`);
+ });
+ },
+ files: {
+ "script-1.js": "",
+ },
+ });
+
+ await extension1.startup();
+
+ info(`Checking StartupCache for ${extension1.id} ${extension1.version}`);
+ await assertHasPersistedScriptsCachedFlag(extension1);
+ await assertIsPersistentScriptsCachedFlag(extension1, false);
+
+ const store = ExtensionScriptingStore._getStoreForTesting();
+
+ extension1.sendMessage("registerContentScripts", [
+ {
+ id: "some-script-id",
+ js: ["script-1.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: true,
+ },
+ ]);
+ await extension1.awaitMessage("registerContentScripts:done");
+
+ // `registerContentScripts()` calls `ExtensionScriptingStore.persistAll()`
+ // without await it, which isn't a problem in practice but this becomes a
+ // problem in this test given that we should make sure the startup cache
+ // is updated before checking it.
+ await TestUtils.waitForCondition(async () => {
+ const scripts = await store.getAll(extension1.id);
+ return !!scripts.length;
+ }, "Wait for stored scripts list to not be empty");
+ await assertIsPersistentScriptsCachedFlag(extension1, true);
+
+ extension1.sendMessage("unregisterContentScripts", {
+ ids: ["some-script-id"],
+ });
+ await extension1.awaitMessage("unregisterContentScripts:done");
+
+ await TestUtils.waitForCondition(async () => {
+ const scripts = await store.getAll(extension1.id);
+ return !scripts.length;
+ }, "Wait for stored scripts list to be empty");
+ await assertIsPersistentScriptsCachedFlag(extension1, false);
+
+ const storeGetAllSpy = sinon.spy(store, "getAll");
+ const cleanupSpies = () => {
+ storeGetAllSpy.restore();
+ };
+
+ // NOTE: ExtensionScriptingStore.initExtension is usually only called once
+ // during the extension startup.
+ //
+ // This test calls the method after startup was completed, which does not
+ // happen in practice, but it allows us to simulate what happens under different
+ // store and startup cache conditions and more explicitly cover the expectation
+ // that store.getAll isn't going to be called more than once internally
+ // when the hasPersistedScripts boolean flag wasn't in the StartupCache
+ // and had to be recomputed.
+ equal(
+ extension1.extension.startupReason,
+ "APP_STARTUP",
+ "Got the expected extension.startupReason"
+ );
+ await ExtensionScriptingStore.initExtension(extension1.extension);
+ equal(storeGetAllSpy.callCount, 0, "Expect store.getAll to not be called");
+
+ Services.obs.notifyObservers(null, "startupcache-invalidate");
+
+ await ExtensionScriptingStore.initExtension(extension1.extension);
+ equal(storeGetAllSpy.callCount, 1, "Expect store.getAll to be called once");
+
+ extension1.sendMessage("registerContentScripts", [
+ {
+ id: "some-script-id",
+ js: ["script-1.js"],
+ matches: ["http://*/*/file_sample.html"],
+ persistAcrossSessions: true,
+ },
+ ]);
+ await extension1.awaitMessage("registerContentScripts:done");
+
+ await TestUtils.waitForCondition(async () => {
+ const scripts = await store.getAll(extension1.id);
+ return !!scripts.length;
+ }, "Wait for stored scripts list to not be empty");
+ await assertIsPersistentScriptsCachedFlag(extension1, true);
+
+ // Make sure getAll is only called once when we don't have
+ // scripting.hasPersistedScripts flag cached.
+ storeGetAllSpy.resetHistory();
+ Services.obs.notifyObservers(null, "startupcache-invalidate");
+ await ExtensionScriptingStore.initExtension(extension1.extension);
+ equal(storeGetAllSpy.callCount, 1, "Expect store.getAll to be called once");
+
+ cleanupSpies();
+
+ const extId = extension1.id;
+ const extVersion = extension1.version;
+ await assertIsPersistentScriptsCachedFlag(
+ { id: extId, version: extVersion },
+ true
+ );
+ await extension1.unload();
+ await assertIsPersistentScriptsCachedFlag(
+ { id: extId, version: extVersion },
+ undefined
+ );
+
+ const { StartupCache } = ExtensionParent;
+ const allCachedGeneral = StartupCache._data.get("general");
+ equal(
+ allCachedGeneral.has(extId),
+ false,
+ "Expect the extension to have been removed from the StartupCache"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_updateContentScripts.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_updateContentScripts.js
new file mode 100644
index 0000000000..9d3bf1576c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_updateContentScripts.js
@@ -0,0 +1,114 @@
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell, to use
+// the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+const makeExtension = ({ manifest: manifestProps, ...otherProps }) => {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ permissions: ["scripting"],
+ host_permissions: ["http://localhost/*"],
+ granted_host_permissions: true,
+ ...manifestProps,
+ },
+ temporarilyInstalled: true,
+ ...otherProps,
+ });
+};
+
+add_task(async function test_scripting_updateContentScripts() {
+ let extension = makeExtension({
+ async background() {
+ const script = {
+ id: "a-script",
+ js: ["script-1.js"],
+ matches: ["http://*/*/*.html"],
+ persistAcrossSessions: false,
+ };
+
+ await browser.scripting.registerContentScripts([script]);
+ await browser.scripting.updateContentScripts([
+ {
+ id: script.id,
+ js: ["script-2.js"],
+ },
+ ]);
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(1, scripts.length, "expected 1 registered script");
+
+ browser.test.sendMessage("background-ready");
+ },
+ files: {
+ "script-1.js": () => {
+ browser.test.fail("script-1 should not be executed");
+ },
+ "script-2.js": () => {
+ browser.test.sendMessage(
+ `script-2 executed in ${location.pathname.split("/").pop()}`
+ );
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.awaitMessage("script-2 executed in file_sample.html");
+
+ await extension.unload();
+ await contentPage.close();
+});
+
+add_task(
+ async function test_scripting_updateContentScripts_non_default_values() {
+ let extension = makeExtension({
+ async background() {
+ const script = {
+ id: "a-script",
+ allFrames: true,
+ matches: ["http://*/*/*.html"],
+ runAt: "document_start",
+ persistAcrossSessions: false,
+ css: ["style.js"],
+ excludeMatches: ["http://*/*/foobar.html"],
+ js: ["script.js"],
+ };
+
+ await browser.scripting.registerContentScripts([script]);
+
+ // This should not modify the previously registered script.
+ await browser.scripting.updateContentScripts([{ id: script.id }]);
+
+ let scripts = await browser.scripting.getRegisteredContentScripts();
+ browser.test.assertEq(
+ JSON.stringify([script]),
+ JSON.stringify(scripts),
+ "expected unmodified registered script"
+ );
+
+ browser.test.sendMessage("background-done");
+ },
+ files: {
+ "script.js": "",
+ "style.css": "",
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-done");
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_secfetch.js b/toolkit/components/extensions/test/xpcshell/test_ext_secfetch.js
new file mode 100644
index 0000000000..e8b3dcfca8
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_secfetch.js
@@ -0,0 +1,352 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+const server = createHttpServer({
+ // We need the 127.0.0.1 proxy because the sec-fetch headers are not sent to
+ // "127.0.0.1:<any port other than 80 or 443>".
+ hosts: ["127.0.0.1", "127.0.0.2"],
+});
+
+server.registerPathHandler("/page.html", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Access-Control-Allow-Origin", "*");
+});
+
+server.registerPathHandler("/return_headers", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/plain");
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ if (request.method === "OPTIONS") {
+ // Handle CORS preflight request.
+ response.setHeader("Access-Control-Allow-Methods", "GET, PUT");
+ return;
+ }
+
+ let headers = {};
+ for (let header of [
+ "sec-fetch-site",
+ "sec-fetch-dest",
+ "sec-fetch-mode",
+ "sec-fetch-user",
+ ]) {
+ if (request.hasHeader(header)) {
+ headers[header] = request.getHeader(header);
+ }
+ }
+
+ if (request.hasHeader("origin")) {
+ headers.origin = request
+ .getHeader("origin")
+ .replace(/moz-extension:\/\/[^\/]+/, "moz-extension://<placeholder>");
+ }
+
+ response.write(JSON.stringify(headers));
+});
+
+async function contentScript() {
+ let content_fetch;
+ if (browser.runtime.getManifest().manifest_version === 2) {
+ content_fetch = content.fetch;
+ } else {
+ // In MV3, there is no content variable.
+ browser.test.assertEq(typeof content, "undefined", "no .content in MV3");
+ // In MV3, window.fetch is the original fetch with the page's principal.
+ content_fetch = window.fetch.bind(window);
+ }
+ let results = await Promise.allSettled([
+ // A cross-origin request from the content script.
+ fetch("http://127.0.0.1/return_headers").then(res => res.json()),
+ // A cross-origin request that behaves as if it was sent by the content it
+ // self.
+ content_fetch("http://127.0.0.1/return_headers").then(res => res.json()),
+ // A same-origin request that behaves as if it was sent by the content it
+ // self.
+ content_fetch("http://127.0.0.2/return_headers").then(res => res.json()),
+ // A same-origin request from the content script.
+ fetch("http://127.0.0.2/return_headers").then(res => res.json()),
+ // Non GET or HEAD request, triggers CORS preflight.
+ fetch("http://127.0.0.2/return_headers", { method: "PUT" }).then(res =>
+ res.json()
+ ),
+ ]);
+
+ results = results.map(({ value, reason }) => value ?? reason.message);
+
+ browser.test.sendMessage("content_results", results);
+}
+
+async function runSecFetchTest(test) {
+ let data = {
+ async background() {
+ let site = await new Promise(resolve => {
+ browser.test.onMessage.addListener(msg => {
+ resolve(msg);
+ });
+ });
+
+ let results = await Promise.all([
+ fetch(`${site}/return_headers`).then(res => res.json()),
+ // Non GET or HEAD request, triggers CORS preflight.
+ fetch(`${site}/return_headers`, { method: "PUT" }).then(res =>
+ res.json()
+ ),
+ ]);
+ browser.test.sendMessage("background_results", results);
+ },
+ manifest: {
+ manifest_version: test.manifest_version,
+ content_scripts: [
+ {
+ matches: ["http://127.0.0.2/*"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+ files: {
+ "content_script.js": contentScript,
+ },
+ };
+
+ if (data.manifest.manifest_version == 3) {
+ // Automatically grant permissions so that the content script can run.
+ data.manifest.granted_host_permissions = true;
+ // Needed to use granted_host_permissions in tests:
+ data.temporarilyInstalled = true;
+ // Work-around for bug 1766752:
+ data.manifest.host_permissions = ["http://127.0.0.2/*"];
+ // (note: ^ host_permissions may be replaced/extended below).
+ }
+
+ // The sec-fetch-* headers are only send to potentially trust worthy origins.
+ // We use 127.0.0.1 to avoid setting up an https server.
+ const site = "http://127.0.0.1";
+
+ if (test.permission) {
+ // MV3 requires permissions to be set in permissions. ExtensionTestCommon
+ // will replace host_permissions with permissions in MV2.
+ data.manifest.host_permissions = ["http://127.0.0.2/*", `${site}/*`];
+ }
+
+ let extension = ExtensionTestUtils.loadExtension(data);
+ await extension.startup();
+
+ extension.sendMessage(site);
+ let backgroundResults = await extension.awaitMessage("background_results");
+ Assert.deepEqual(backgroundResults, test.expectedBackgroundHeaders);
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `http://127.0.0.2/page.html`
+ );
+ let contentResults = await extension.awaitMessage("content_results");
+ Assert.deepEqual(contentResults, test.expectedContentHeaders);
+ await contentPage.close();
+
+ await extension.unload();
+}
+
+add_task(async function test_fetch_without_permissions_mv2() {
+ await runSecFetchTest({
+ manifest_version: 2,
+ permission: false,
+ expectedBackgroundHeaders: [
+ {
+ "sec-fetch-site": "cross-site",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "moz-extension://<placeholder>",
+ },
+ {
+ "sec-fetch-site": "cross-site",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "moz-extension://<placeholder>",
+ },
+ ],
+ expectedContentHeaders: [
+ // TODO bug 1605197: Support cors without permissions in MV2.
+ "NetworkError when attempting to fetch resource.",
+ // Expectation:
+ // {
+ // "sec-fetch-site": "cross-site",
+ // "sec-fetch-mode": "cors",
+ // "sec-fetch-dest": "empty",
+ // },
+ {
+ "sec-fetch-site": "cross-site",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "http://127.0.0.2",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ },
+ ],
+ });
+});
+
+add_task(async function test_fetch_with_permissions_mv2() {
+ await runSecFetchTest({
+ manifest_version: 2,
+ permission: true,
+ expectedBackgroundHeaders: [
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "moz-extension://<placeholder>",
+ },
+ ],
+ expectedContentHeaders: [
+ {
+ "sec-fetch-site": "cross-site",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ },
+ {
+ "sec-fetch-site": "cross-site",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "http://127.0.0.2",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ },
+ ],
+ });
+});
+
+add_task(async function test_fetch_without_permissions_mv3() {
+ await runSecFetchTest({
+ manifest_version: 3,
+ permission: false,
+ expectedBackgroundHeaders: [
+ // Same as in test_fetch_without_permissions_mv2.
+ {
+ "sec-fetch-site": "cross-site",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "moz-extension://<placeholder>",
+ },
+ {
+ "sec-fetch-site": "cross-site",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "moz-extension://<placeholder>",
+ },
+ ],
+ expectedContentHeaders: [
+ {
+ "sec-fetch-site": "cross-site",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "http://127.0.0.2",
+ },
+ {
+ "sec-fetch-site": "cross-site",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "http://127.0.0.2",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "http://127.0.0.2",
+ },
+ ],
+ });
+});
+
+add_task(async function test_fetch_with_permissions_mv3() {
+ await runSecFetchTest({
+ manifest_version: 3,
+ permission: true,
+ expectedBackgroundHeaders: [
+ {
+ // Same as in test_fetch_with_permissions_mv2.
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "moz-extension://<placeholder>",
+ },
+ ],
+ expectedContentHeaders: [
+ // All expectations the same as in test_fetch_without_permissions_mv3.
+ {
+ "sec-fetch-site": "cross-site",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "http://127.0.0.2",
+ },
+ {
+ "sec-fetch-site": "cross-site",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "http://127.0.0.2",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ },
+ {
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-dest": "empty",
+ origin: "http://127.0.0.2",
+ },
+ ],
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js b/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js
new file mode 100644
index 0000000000..626d8de22d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js
@@ -0,0 +1,59 @@
+"use strict";
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function test_contentscript_shadowDOM() {
+ function backgroundScript() {
+ browser.test.assertTrue(
+ "openOrClosedShadowRoot" in document.documentElement,
+ "Should have openOrClosedShadowRoot in Element in background script."
+ );
+ }
+
+ function contentScript() {
+ let host = document.getElementById("host");
+ browser.test.assertTrue(
+ "openOrClosedShadowRoot" in host,
+ "Should have openOrClosedShadowRoot in Element."
+ );
+ let shadowRoot = host.openOrClosedShadowRoot;
+ browser.test.assertEq(
+ shadowRoot.mode,
+ "closed",
+ "Should have closed ShadowRoot."
+ );
+ browser.test.sendMessage("contentScript");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_shadowdom.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+ background: backgroundScript,
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_shadowdom.html`
+ );
+ await extension.awaitMessage("contentScript");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shared_array_buffer.js b/toolkit/components/extensions/test/xpcshell/test_ext_shared_array_buffer.js
new file mode 100644
index 0000000000..b2a9d81a27
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_shared_array_buffer.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_shared_array_buffer_worker() {
+ const extension_description = {
+ isPrivileged: null,
+ async background() {
+ browser.test.onMessage.addListener(async isPrivileged => {
+ const worker = new Worker("worker.js");
+ worker.isPrivileged = isPrivileged;
+ worker.onmessage = function (e) {
+ const msg = `${
+ this.isPrivileged
+ ? "privileged addon can"
+ : "non-privileged addon can't"
+ } instantiate a SharedArrayBuffer
+ in a worker`;
+ if (e.data === this.isPrivileged) {
+ browser.test.succeed(msg);
+ } else {
+ browser.test.fail(msg);
+ }
+ browser.test.sendMessage("test-sab-worker:done");
+ };
+ });
+ },
+ files: {
+ "worker.js": function () {
+ try {
+ new SharedArrayBuffer(1);
+ this.postMessage(true);
+ } catch (e) {
+ this.postMessage(false);
+ }
+ },
+ },
+ };
+
+ // This test attempts to verify that a worker inside a privileged addon
+ // is allowed to instantiate a SharedArrayBuffer
+ extension_description.isPrivileged = true;
+ let extension = ExtensionTestUtils.loadExtension(extension_description);
+ await extension.startup();
+ extension.sendMessage(extension_description.isPrivileged);
+ await extension.awaitMessage("test-sab-worker:done");
+ await extension.unload();
+
+ // This test attempts to verify that a worker inside a non privileged addon
+ // is not allowed to instantiate a SharedArrayBuffer
+ extension_description.isPrivileged = false;
+ extension = ExtensionTestUtils.loadExtension(extension_description);
+ await extension.startup();
+ extension.sendMessage(extension_description.isPrivileged);
+ await extension.awaitMessage("test-sab-worker:done");
+ await extension.unload();
+});
+
+add_task(async function test_shared_array_buffer_content() {
+ let extension_description = {
+ isPrivileged: null,
+ async background() {
+ browser.test.onMessage.addListener(async isPrivileged => {
+ let succeed = null;
+ try {
+ new SharedArrayBuffer(1);
+ succeed = true;
+ } catch (e) {
+ succeed = false;
+ } finally {
+ const msg = `${
+ isPrivileged ? "privileged addon can" : "non-privileged addon can't"
+ } instantiate a SharedArrayBuffer
+ in the main thread`;
+ if (succeed === isPrivileged) {
+ browser.test.succeed(msg);
+ } else {
+ browser.test.fail(msg);
+ }
+ browser.test.sendMessage("test-sab-content:done");
+ }
+ });
+ },
+ };
+
+ // This test attempts to verify that a non privileged addon
+ // is allowed to instantiate a sharedarraybuffer
+ extension_description.isPrivileged = true;
+ let extension = ExtensionTestUtils.loadExtension(extension_description);
+ await extension.startup();
+ extension.sendMessage(extension_description.isPrivileged);
+ await extension.awaitMessage("test-sab-content:done");
+ await extension.unload();
+
+ // This test attempts to verify that a non privileged addon
+ // is not allowed to instantiate a sharedarraybuffer
+ extension_description.isPrivileged = false;
+ extension = ExtensionTestUtils.loadExtension(extension_description);
+ await extension.startup();
+ extension.sendMessage(extension_description.isPrivileged);
+ await extension.awaitMessage("test-sab-content:done");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js b/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js
new file mode 100644
index 0000000000..54c14e5524
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This test attemps to verify that:
+// - SharedWorkers can be created and successfully spawned by web extensions
+// when web-extensions run in their own child process.
+add_task(async function test_spawn_shared_worker() {
+ if (!WebExtensionPolicy.useRemoteWebExtensions) {
+ // Ensure RemoteWorkerService has been initialized in the main
+ // process.
+ Services.obs.notifyObservers(null, "profile-after-change");
+ }
+
+ const background = async function () {
+ const worker = new SharedWorker("worker.js");
+ await new Promise(resolve => {
+ worker.port.onmessage = resolve;
+ worker.port.postMessage("bgpage->worker");
+ });
+ browser.test.sendMessage("test-shared-worker:done");
+ };
+
+ const extension = ExtensionTestUtils.loadExtension({
+ background,
+ files: {
+ "worker.js": function () {
+ self.onconnect = evt => {
+ const port = evt.ports[0];
+ port.onmessage = () => port.postMessage("worker-reply");
+ };
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("test-shared-worker:done");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js b/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js
new file mode 100644
index 0000000000..f423ccbe97
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js
@@ -0,0 +1,43 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+const {
+ ExtensionParent: { GlobalManager },
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+add_task(async function test_global_manager_shutdown_cleanup() {
+ equal(
+ GlobalManager.initialized,
+ false,
+ "GlobalManager start as not initialized"
+ );
+
+ function background() {
+ browser.test.notifyPass("background page loaded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("background page loaded");
+
+ equal(
+ GlobalManager.initialized,
+ true,
+ "GlobalManager has been initialized once an extension is started"
+ );
+
+ await extension.unload();
+
+ equal(
+ GlobalManager.initialized,
+ false,
+ "GlobalManager has been uninitialized once all the webextensions have been stopped"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_simple.js b/toolkit/components/extensions/test/xpcshell/test_ext_simple.js
new file mode 100644
index 0000000000..ad74d80ede
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_simple.js
@@ -0,0 +1,208 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+add_task(async function test_simple() {
+ let extensionData = {
+ manifest: {
+ name: "Simple extension test",
+ version: "1.0",
+ manifest_version: 2,
+ description: "",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.unload();
+});
+
+add_task(async function test_manifest_V3_disabled() {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", false);
+ let extensionData = {
+ manifest: {
+ manifest_version: 3,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await Assert.rejects(
+ extension.startup(),
+ /Unsupported manifest version: 3/,
+ "manifest V3 cannot be loaded"
+ );
+ Services.prefs.clearUserPref("extensions.manifestV3.enabled");
+});
+
+add_task(async function test_manifest_V3_enabled() {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ let extensionData = {
+ manifest: {
+ manifest_version: 3,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ equal(extension.extension.manifest.manifest_version, 3, "manifest V3 loads");
+ await extension.unload();
+ Services.prefs.clearUserPref("extensions.manifestV3.enabled");
+});
+
+add_task(async function test_background() {
+ function background() {
+ browser.test.log("running background script");
+
+ browser.test.onMessage.addListener((x, y) => {
+ browser.test.assertEq(x, 10, "x is 10");
+ browser.test.assertEq(y, 20, "y is 20");
+
+ browser.test.notifyPass("background test passed");
+ });
+
+ browser.test.sendMessage("running", 1);
+ }
+
+ let extensionData = {
+ background,
+ manifest: {
+ name: "Simple extension test",
+ version: "1.0",
+ manifest_version: 2,
+ description: "",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ let [, x] = await Promise.all([
+ extension.startup(),
+ extension.awaitMessage("running"),
+ ]);
+ equal(x, 1, "got correct value from extension");
+
+ extension.sendMessage(10, 20);
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_extensionTypes() {
+ let extensionData = {
+ background: function () {
+ browser.test.assertEq(
+ typeof browser.extensionTypes,
+ "object",
+ "browser.extensionTypes exists"
+ );
+ browser.test.assertEq(
+ typeof browser.extensionTypes.RunAt,
+ "object",
+ "browser.extensionTypes.RunAt exists"
+ );
+ browser.test.notifyPass("extentionTypes test passed");
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_policy_temporarilyInstalled() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extensionData = {
+ manifest: {
+ manifest_version: 2,
+ },
+ };
+
+ async function runTest(useAddonManager) {
+ let extension = ExtensionTestUtils.loadExtension({
+ ...extensionData,
+ useAddonManager,
+ });
+
+ const expected = useAddonManager === "temporary";
+ await extension.startup();
+ const { temporarilyInstalled } = WebExtensionPolicy.getByID(extension.id);
+ equal(
+ temporarilyInstalled,
+ expected,
+ `Got the expected WebExtensionPolicy.temporarilyInstalled value on "${useAddonManager}"`
+ );
+ await extension.unload();
+ }
+
+ await runTest("temporary");
+ await runTest("permanent");
+});
+
+add_task(async function test_manifest_allowInsecureRequests() {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ let extensionData = {
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ equal(
+ extension.extension.manifest.content_security_policy.extension_pages,
+ `script-src 'self'`,
+ "insecure allowed"
+ );
+ await extension.unload();
+ Services.prefs.clearUserPref("extensions.manifestV3.enabled");
+});
+
+add_task(async function test_manifest_allowInsecureRequests_throws() {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ let extensionData = {
+ allowInsecureRequests: true,
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self'`,
+ },
+ },
+ };
+
+ await Assert.throws(
+ () => ExtensionTestUtils.loadExtension(extensionData),
+ /allowInsecureRequests cannot be used with manifest.content_security_policy/,
+ "allowInsecureRequests with content_security_policy cannot be loaded"
+ );
+ Services.prefs.clearUserPref("extensions.manifestV3.enabled");
+});
+
+add_task(async function test_gecko_android_key_in_applications() {
+ const extensionData = {
+ manifest: {
+ manifest_version: 2,
+ applications: {
+ gecko_android: {},
+ },
+ },
+ };
+ const extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await Assert.rejects(
+ extension.startup(),
+ /applications: Property "gecko_android" is unsupported by Firefox/,
+ "expected applications.gecko_android to be invalid"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js b/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js
new file mode 100644
index 0000000000..df51fa9abf
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js
@@ -0,0 +1,55 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1"
+);
+
+// Tests that startupData is persisted and is available at startup
+add_task(async function test_startupData() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let wrapper = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ });
+ await wrapper.startup();
+
+ let { extension } = wrapper;
+
+ deepEqual(
+ extension.startupData,
+ {},
+ "startupData for a new extension defaults to empty object"
+ );
+
+ const DATA = { test: "i am some startup data" };
+ extension.startupData = DATA;
+ extension.saveStartupData();
+
+ await AddonTestUtils.promiseRestartManager();
+ await wrapper.startupPromise;
+
+ ({ extension } = wrapper);
+ deepEqual(extension.startupData, DATA, "startupData is present on restart");
+
+ const DATA2 = { other: "this is different data" };
+ extension.startupData = DATA2;
+ extension.saveStartupData();
+
+ await AddonTestUtils.promiseRestartManager();
+ await wrapper.startupPromise;
+
+ ({ extension } = wrapper);
+ deepEqual(
+ extension.startupData,
+ DATA2,
+ "updated startupData is present on restart"
+ );
+
+ await wrapper.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js
new file mode 100644
index 0000000000..ac815d6010
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js
@@ -0,0 +1,178 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+const ADDON_ID = "test-startup-cache@xpcshell.mozilla.org";
+
+function makeExtension(opts) {
+ return {
+ useAddonManager: "permanent",
+
+ manifest: {
+ version: opts.version,
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+
+ name: "__MSG_name__",
+
+ default_locale: "en_US",
+ },
+
+ files: {
+ "_locales/en_US/messages.json": {
+ name: {
+ message: `en-US ${opts.version}`,
+ description: "Name.",
+ },
+ },
+ "_locales/fr/messages.json": {
+ name: {
+ message: `fr ${opts.version}`,
+ description: "Name.",
+ },
+ },
+ },
+
+ background() {
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "get-manifest") {
+ browser.test.sendMessage("manifest", browser.runtime.getManifest());
+ }
+ });
+ },
+ };
+}
+
+add_task(async function test_langpack_startup_cache() {
+ Preferences.set("extensions.logging.enabled", false);
+ await AddonTestUtils.promiseStartupManager();
+
+ // Install langpacks to get proper locale startup.
+ let langpack = {
+ "manifest.json": {
+ name: "test Language Pack",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: "@test-langpack",
+ strict_min_version: "42.0",
+ strict_max_version: "42.0",
+ },
+ },
+ langpack_id: "fr",
+ languages: {
+ fr: {
+ chrome_resources: {
+ global: "chrome/fr/locale/fr/global/",
+ },
+ version: "20171001190118",
+ },
+ },
+ sources: {
+ browser: {
+ base_path: "browser/",
+ },
+ },
+ },
+ };
+
+ let [, { addon }] = await Promise.all([
+ TestUtils.topicObserved("webextension-langpack-startup"),
+ AddonTestUtils.promiseInstallXPI(langpack),
+ ]);
+
+ let extension = ExtensionTestUtils.loadExtension(
+ makeExtension({ version: "1.0" })
+ );
+
+ function getManifest() {
+ extension.sendMessage("get-manifest");
+ return extension.awaitMessage("manifest");
+ }
+
+ // At the moment extension language negotiation is tied to Firefox language
+ // negotiation result. That means that to test an extension in `fr`, we need
+ // to mock `fr` being available in Firefox and then request it.
+ //
+ // In the future, we should provide some way for tests to decouple their
+ // language selection from that of Firefox.
+ ok(Services.locale.availableLocales.includes("fr"), "fr locale is avialable");
+
+ await extension.startup();
+
+ equal(extension.version, "1.0", "Expected extension version");
+ let manifest = await getManifest();
+ equal(manifest.name, "en-US 1.0", "Got expected manifest name");
+
+ info("Restart and re-check");
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitBackgroundStarted();
+
+ equal(extension.version, "1.0", "Expected extension version");
+ manifest = await getManifest();
+ equal(manifest.name, "en-US 1.0", "Got expected manifest name");
+
+ info("Change locale to 'fr' and restart");
+ Services.locale.requestedLocales = ["fr"];
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitBackgroundStarted();
+
+ equal(extension.version, "1.0", "Expected extension version");
+ manifest = await getManifest();
+ equal(manifest.name, "fr 1.0", "Got expected manifest name");
+
+ info("Update to version 1.1");
+ await extension.upgrade(makeExtension({ version: "1.1" }));
+
+ equal(extension.version, "1.1", "Expected extension version");
+ manifest = await getManifest();
+ equal(manifest.name, "fr 1.1", "Got expected manifest name");
+
+ info("Change locale to 'en-US' and restart");
+ Services.locale.requestedLocales = ["en-US"];
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitBackgroundStarted();
+
+ equal(extension.version, "1.1", "Expected extension version");
+ manifest = await getManifest();
+ equal(manifest.name, "en-US 1.1", "Got expected manifest name");
+
+ info("Disable locale 'fr'");
+ addon = await AddonManager.getAddonByID("@test-langpack");
+
+ // We disable the installed langpack instead of uninstalling it
+ // because the xpi file may technically be still in use by the
+ // time the XPIProvider will try to remove the file and will
+ // make this test to fail intermittently on windows.
+ //
+ // Disabling the addon is equivalent from the perspective of this
+ // test case, and the langpack xpi will be uninstalled automatically
+ // at the end of this test case by AddonTestUtils (from its
+ // cleanupTempXPIs method, which will also force a GC if the
+ // file fails to be removed after we flushed the jar cache).
+ await addon.disable();
+ ok(!Services.locale.availableLocales.includes("fr"), "fr locale is removed");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache_telemetry.js
new file mode 100644
index 0000000000..f26e917220
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache_telemetry.js
@@ -0,0 +1,162 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+const ADDON_ID = "test-startup-cache-telemetry@xpcshell.mozilla.org";
+
+add_setup(async () => {
+ // FOG needs a profile directory to put its data in.
+ do_get_profile();
+ // FOG needs to be initialized in order for data to flow.
+ Services.fog.initializeFOG();
+
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_startupCache_write_byteLength() {
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ },
+ });
+
+ await extension.startup();
+
+ const { StartupCache } = ExtensionParent;
+
+ const aomStartup = Cc[
+ "@mozilla.org/addons/addon-manager-startup;1"
+ ].getService(Ci.amIAddonManagerStartup);
+
+ let expectedByteLength = new Uint8Array(
+ aomStartup.encodeBlob(StartupCache._data)
+ ).byteLength;
+
+ equal(
+ typeof expectedByteLength,
+ "number",
+ "Got a numeric byteLength for the expected startupCache data"
+ );
+ ok(expectedByteLength > 0, "Got a non-zero byteLength as expected");
+ await StartupCache._saveNow();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent");
+ equal(
+ scalars["extensions.startupCache.write_byteLength"],
+ expectedByteLength,
+ "Got the expected value set in the 'extensions.startupCache.write_byteLength' scalar"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_startupCache_read_errors() {
+ const { StartupCache } = ExtensionParent;
+
+ // Clear any pre-existing keyed scalar.
+ TelemetryTestUtils.getProcessScalars("parent", /* keyed */ true, true);
+
+ // Temporarily point StartupCache._file to a path that is
+ // not going to exist for sure.
+ Assert.notEqual(
+ StartupCache.file,
+ null,
+ "Got a StartupCache._file non-null property as expected"
+ );
+ const oldFile = StartupCache.file;
+ const restoreStartupCacheFile = () => (StartupCache.file = oldFile);
+ StartupCache.file = `${StartupCache.file}.non_existing_file.${Math.random()}`;
+ registerCleanupFunction(restoreStartupCacheFile);
+
+ // Make sure the _readData has been called and we can expect
+ // the extensions.startupCache.read_errors scalar to have
+ // been recorded.
+ await StartupCache._readData();
+
+ let scalars = TelemetryTestUtils.getProcessScalars(
+ "parent",
+ /* keyed */ true
+ );
+ Assert.deepEqual(
+ scalars["extensions.startupCache.read_errors"],
+ {
+ NotFoundError: 1,
+ },
+ "Got the expected value set in the 'extensions.startupCache.read_errors' keyed scalar"
+ );
+
+ restoreStartupCacheFile();
+});
+
+async function test_startupCache_load_timestamps() {
+ const { StartupCache } = ExtensionParent;
+
+ // Clear any pre-existing keyed scalar and Glean metrics data.
+ TelemetryTestUtils.getProcessScalars("parent", false, true);
+ Services.fog.testResetFOG();
+
+ let gleanMetric = Glean.extensions.startupCacheLoadTime.testGetValue();
+ equal(
+ typeof gleanMetric,
+ "undefined",
+ "Expect extensions.startup_cache_load_time Glean metric to be initially undefined"
+ );
+
+ // Make sure the _readData has been called and we can expect
+ // the startupCache load telemetry timestamps to have been
+ // recorded.
+ await StartupCache._readData();
+
+ info(
+ "Verify telemetry recorded for the 'extensions.startup_cache_load_time' Glean metric"
+ );
+
+ gleanMetric = Glean.extensions.startupCacheLoadTime.testGetValue();
+ equal(
+ typeof gleanMetric,
+ "number",
+ "Expect extensions.startup_cache_load_time Glean metric to be set to a number"
+ );
+
+ info(
+ "Verify telemetry mirrored into the 'extensions.startupCache.load_time' scalar"
+ );
+
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", false, true);
+
+ equal(
+ typeof scalars["extensions.startupCache.load_time"],
+ "number",
+ "Expect extensions.startupCache.load_time mirrored scalar to be set to a number"
+ );
+
+ equal(
+ scalars["extensions.startupCache.load_time"],
+ gleanMetric,
+ "Expect the glean metric and mirrored scalar to be set to the same value"
+ );
+}
+
+add_task(
+ // Bug 1752139: this test can be re-enabled once Services.fog.testResetFOG()
+ // is implemented also on Android.
+ { skip_if: () => AppConstants.platform === "android" },
+ test_startupCache_load_timestamps
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js
new file mode 100644
index 0000000000..e7108ce100
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js
@@ -0,0 +1,70 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const STARTUP_APIS = ["backgroundPage"];
+
+const STARTUP_MODULES = new Set([
+ "resource://gre/modules/Extension.sys.mjs",
+ "resource://gre/modules/ExtensionCommon.sys.mjs",
+ "resource://gre/modules/ExtensionParent.sys.mjs",
+ // FIXME: This is only loaded at startup for new extension installs.
+ // Otherwise the data comes from the startup cache. We should test for
+ // this.
+ "resource://gre/modules/ExtensionPermissions.sys.mjs",
+ "resource://gre/modules/ExtensionProcessScript.sys.mjs",
+ "resource://gre/modules/ExtensionUtils.sys.mjs",
+ "resource://gre/modules/ExtensionTelemetry.sys.mjs",
+]);
+
+if (!Services.prefs.getBoolPref("extensions.webextensions.remote")) {
+ STARTUP_MODULES.add("resource://gre/modules/ExtensionChild.sys.mjs");
+ STARTUP_MODULES.add("resource://gre/modules/ExtensionPageChild.sys.mjs");
+}
+
+if (AppConstants.MOZ_APP_NAME == "thunderbird") {
+ // Imported via mail/components/extensions/processScript.js.
+ STARTUP_MODULES.add("resource://gre/modules/ExtensionChild.sys.mjs");
+ STARTUP_MODULES.add("resource://gre/modules/ExtensionContent.sys.mjs");
+ STARTUP_MODULES.add("resource://gre/modules/ExtensionPageChild.sys.mjs");
+}
+
+AddonTestUtils.init(this);
+
+// Tests that only the minimal set of API scripts and modules are loaded at
+// startup for a simple extension.
+add_task(async function test_loaded_scripts() {
+ await ExtensionTestUtils.startAddonManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ background() {},
+ manifest: {},
+ });
+
+ await extension.startup();
+
+ const { apiManager } = ExtensionParent;
+
+ const loadedAPIs = Array.from(apiManager.modules.values())
+ .filter(m => m.loaded || m.asyncLoaded)
+ .map(m => m.namespaceName);
+
+ deepEqual(
+ loadedAPIs.sort(),
+ STARTUP_APIS,
+ "No extra APIs should be loaded at startup for a simple extension"
+ );
+
+ let loadedModules = Cu.loadedJSModules
+ .concat(Cu.loadedESModules)
+ .filter(url => url.startsWith("resource://gre/modules/Extension"));
+
+ deepEqual(
+ loadedModules.sort(),
+ Array.from(STARTUP_MODULES).sort(),
+ "No extra extension modules should be loaded at startup for a simple extension"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js
new file mode 100644
index 0000000000..2311f76105
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js
@@ -0,0 +1,64 @@
+"use strict";
+
+function delay(time) {
+ return new Promise(resolve => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(resolve, time);
+ });
+}
+
+const { Extension } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+);
+
+add_task(async function test_startup_request_handler() {
+ const ID = "request-startup@xpcshell.mozilla.org";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+
+ files: {
+ "meh.txt": "Meh.",
+ },
+ });
+
+ let ready = false;
+ let resolvePromise;
+ let promise = new Promise(resolve => {
+ resolvePromise = resolve;
+ });
+ promise.then(() => {
+ ready = true;
+ });
+
+ let origInitLocale = Extension.prototype.initLocale;
+ Extension.prototype.initLocale = async function initLocale() {
+ await promise;
+ return origInitLocale.call(this);
+ };
+
+ let startupPromise = extension.startup();
+
+ await delay(0);
+ let policy = WebExtensionPolicy.getByID(ID);
+ let url = policy.getURL("meh.txt");
+
+ let resp = ExtensionTestUtils.fetch(url, url);
+ resp.then(() => {
+ ok(ready, "Shouldn't get response before extension is ready");
+ });
+
+ await delay(2000);
+
+ resolvePromise();
+ await startupPromise;
+
+ let body = await resp;
+ equal(body, "Meh.", "Got the correct response");
+
+ await extension.unload();
+
+ Extension.prototype.initLocale = origInitLocale;
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js
new file mode 100644
index 0000000000..4ba8cc596b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js
@@ -0,0 +1,39 @@
+"use strict";
+
+const { ExtensionStorageIDB } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorageIDB.sys.mjs"
+);
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /WebExtension context not found/
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+// The storage API in content scripts should behave identical to the storage API
+// in background pages.
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_contentscript_storage_local_file_backend() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], () =>
+ test_contentscript_storage("local")
+ );
+});
+
+add_task(async function test_contentscript_storage_local_idb_backend() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () =>
+ test_contentscript_storage("local")
+ );
+});
+
+add_task(async function test_contentscript_storage_local_idb_no_bytes_in_use() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () =>
+ test_contentscript_storage_area_no_bytes_in_use("local")
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js
new file mode 100644
index 0000000000..6b1695417d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js
@@ -0,0 +1,31 @@
+"use strict";
+
+Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false);
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /WebExtension context not found/
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+// The storage API in content scripts should behave identical to the storage API
+// in background pages.
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_contentscript_storage_sync() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_contentscript_storage("sync")
+ );
+});
+
+add_task(async function test_contentscript_bytes_in_use_sync() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_contentscript_storage_area_with_bytes_in_use("sync", true)
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js
new file mode 100644
index 0000000000..92ec405520
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js
@@ -0,0 +1,31 @@
+"use strict";
+
+Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true);
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /WebExtension context not found/
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+// The storage API in content scripts should behave identical to the storage API
+// in background pages.
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_contentscript_storage_sync() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_contentscript_storage("sync")
+ );
+});
+
+add_task(async function test_contentscript_storage_no_bytes_in_use() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_contentscript_storage_area_with_bytes_in_use("sync", false)
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js
new file mode 100644
index 0000000000..8a426c3669
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js
@@ -0,0 +1,790 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// This test file verifies various scenarios related to the data migration
+// from the JSONFile backend to the IDB backend.
+
+AddonTestUtils.init(this);
+
+// Create appInfo before importing any other jsm file, to prevent
+// Services.appinfo to be cached before an appInfo.version is
+// actually defined (which prevent failures to be triggered when
+// the test run in a non nightly build).
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+const { getTrimmedString } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionTelemetry.sys.mjs"
+);
+const { ExtensionStorage } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorage.sys.mjs"
+);
+const { ExtensionStorageIDB } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorageIDB.sys.mjs"
+);
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const { promiseShutdownManager, promiseStartupManager } = AddonTestUtils;
+
+const { IDB_MIGRATED_PREF_BRANCH, IDB_MIGRATE_RESULT_HISTOGRAM } =
+ ExtensionStorageIDB;
+const CATEGORIES = ["success", "failure"];
+const EVENT_CATEGORY = "extensions.data";
+const EVENT_OBJECT = "storageLocal";
+const EVENT_METHOD = "migrateResult";
+const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall";
+const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall";
+const TELEMETRY_EVENTS_FILTER = {
+ category: "extensions.data",
+ method: "migrateResult",
+ object: "storageLocal",
+};
+
+async function createExtensionJSONFileWithData(extensionId, data) {
+ await ExtensionStorage.set(extensionId, data);
+ const jsonFile = await ExtensionStorage.getFile(extensionId);
+ await jsonFile._save();
+ const oldStorageFilename = ExtensionStorage.getStorageFile(extensionId);
+ equal(
+ await IOUtils.exists(oldStorageFilename),
+ true,
+ "The old json file has been created"
+ );
+
+ return { jsonFile, oldStorageFilename };
+}
+
+function clearMigrationHistogram() {
+ const histogram = Services.telemetry.getHistogramById(
+ IDB_MIGRATE_RESULT_HISTOGRAM
+ );
+ histogram.clear();
+ equal(
+ histogram.snapshot().sum,
+ 0,
+ `No data recorded for histogram ${IDB_MIGRATE_RESULT_HISTOGRAM}`
+ );
+}
+
+function assertMigrationHistogramCount(category, expectedCount) {
+ const histogram = Services.telemetry.getHistogramById(
+ IDB_MIGRATE_RESULT_HISTOGRAM
+ );
+
+ equal(
+ histogram.snapshot().values[CATEGORIES.indexOf(category)],
+ expectedCount,
+ `Got the expected count on category "${category}" for histogram ${IDB_MIGRATE_RESULT_HISTOGRAM}`
+ );
+}
+
+function assertTelemetryEvents(expectedEvents) {
+ TelemetryTestUtils.assertEvents(expectedEvents, {
+ category: EVENT_CATEGORY,
+ method: EVENT_METHOD,
+ object: EVENT_OBJECT,
+ });
+}
+
+add_setup(async function setup() {
+ Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, true);
+
+ await promiseStartupManager();
+
+ // Telemetry test setup needed to ensure that the builtin events are defined
+ // and they can be collected and verified.
+ await TelemetryController.testSetup();
+
+ // This is actually only needed on Android, because it does not properly support unified telemetry
+ // and so, if not enabled explicitly here, it would make these tests to fail when running on a
+ // non-Nightly build.
+ const oldCanRecordBase = Services.telemetry.canRecordBase;
+ Services.telemetry.canRecordBase = true;
+ registerCleanupFunction(() => {
+ Services.telemetry.canRecordBase = oldCanRecordBase;
+ });
+
+ // Clear any telemetry events collected so far.
+ Services.telemetry.clearEvents();
+});
+
+// Test that for newly installed extension the IDB backend is enabled without
+// any data migration.
+add_task(async function test_no_migration_for_newly_installed_extensions() {
+ const EXTENSION_ID = "test-no-data-migration@mochi.test";
+
+ await createExtensionJSONFileWithData(EXTENSION_ID, {
+ test_old_data: "test_old_value",
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: { gecko: { id: EXTENSION_ID } },
+ },
+ async background() {
+ const data = await browser.storage.local.get();
+ browser.test.assertEq(
+ Object.keys(data).length,
+ 0,
+ "Expect the storage.local store to be empty"
+ );
+ browser.test.sendMessage("test-stored-data:done");
+ },
+ });
+
+ await extension.startup();
+ equal(
+ ExtensionStorageIDB.isMigratedExtension(extension),
+ true,
+ "The newly installed test extension is marked as migrated"
+ );
+ await extension.awaitMessage("test-stored-data:done");
+ await extension.unload();
+
+ // Verify that no data migration have been needed on the newly installed
+ // extension, by asserting that no telemetry events has been collected.
+ await TelemetryTestUtils.assertEvents([], TELEMETRY_EVENTS_FILTER);
+});
+
+// Test that the data migration is still running for a newly installed extension
+// if keepStorageOnUninstall is true.
+add_task(async function test_data_migration_on_keep_storage_on_uninstall() {
+ Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true);
+
+ // Store some fake data in the storage.local file backend before starting the extension.
+ const EXTENSION_ID = "new-extension-on-keep-storage-on-uninstall@mochi.test";
+ await createExtensionJSONFileWithData(EXTENSION_ID, {
+ test_key_string: "test_value",
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: { gecko: { id: EXTENSION_ID } },
+ },
+ async background() {
+ const storedData = await browser.storage.local.get();
+ browser.test.assertEq(
+ "test_value",
+ storedData.test_key_string,
+ "Got the expected data after the storage.local data migration"
+ );
+ browser.test.sendMessage("storage-local-data-migrated");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("storage-local-data-migrated");
+ equal(
+ ExtensionStorageIDB.isMigratedExtension(extension),
+ true,
+ "The newly installed test extension is marked as migrated"
+ );
+ await extension.unload();
+
+ // Verify that the expected telemetry has been recorded.
+ await TelemetryTestUtils.assertEvents(
+ [
+ {
+ method: "migrateResult",
+ value: EXTENSION_ID,
+ extra: {
+ backend: "IndexedDB",
+ data_migrated: "y",
+ has_jsonfile: "y",
+ has_olddata: "y",
+ },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTER
+ );
+
+ Services.prefs.clearUserPref(LEAVE_STORAGE_PREF);
+});
+
+// Test that the old data is migrated successfully to the new storage backend
+// and that the original JSONFile has been renamed.
+add_task(async function test_storage_local_data_migration() {
+ const EXTENSION_ID = "extension-to-be-migrated@mozilla.org";
+
+ // Keep the extension storage and the uuid on uninstall, to verify that no telemetry events
+ // are being sent for an already migrated extension.
+ Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true);
+ Services.prefs.setBoolPref(LEAVE_UUID_PREF, true);
+
+ const data = {
+ test_key_string: "test_value1",
+ test_key_number: 1000,
+ test_nested_data: {
+ nested_key: true,
+ },
+ };
+
+ // Store some fake data in the storage.local file backend before starting the extension.
+ const { oldStorageFilename } = await createExtensionJSONFileWithData(
+ EXTENSION_ID,
+ data
+ );
+
+ async function background() {
+ const storedData = await browser.storage.local.get();
+
+ browser.test.assertEq(
+ "test_value1",
+ storedData.test_key_string,
+ "Got the expected data after the storage.local data migration"
+ );
+ browser.test.assertEq(
+ 1000,
+ storedData.test_key_number,
+ "Got the expected data after the storage.local data migration"
+ );
+ browser.test.assertEq(
+ true,
+ storedData.test_nested_data.nested_key,
+ "Got the expected data after the storage.local data migration"
+ );
+
+ browser.test.sendMessage("storage-local-data-migrated");
+ }
+
+ clearMigrationHistogram();
+
+ let extensionDefinition = {
+ useAddonManager: "temporary",
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionDefinition);
+
+ // Install the extension while the storage.local IDB backend is disabled.
+ Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, false);
+ await extension.startup();
+
+ ok(
+ !ExtensionStorageIDB.isMigratedExtension(extension),
+ "The test extension should be using the JSONFile backend"
+ );
+
+ // Enabled the storage.local IDB backend and upgrade the extension.
+ Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, true);
+ await extension.upgrade({
+ ...extensionDefinition,
+ background,
+ });
+
+ await extension.awaitMessage("storage-local-data-migrated");
+
+ ok(
+ ExtensionStorageIDB.isMigratedExtension(extension),
+ "The test extension should be using the IndexedDB backend"
+ );
+
+ const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(
+ extension.extension
+ );
+
+ const idbConn = await ExtensionStorageIDB.open(storagePrincipal);
+
+ equal(
+ await idbConn.isEmpty(extension.extension),
+ false,
+ "Data stored in the ExtensionStorageIDB backend as expected"
+ );
+
+ equal(
+ await IOUtils.exists(oldStorageFilename),
+ false,
+ "The old json storage file name should not exist anymore"
+ );
+
+ equal(
+ await IOUtils.exists(`${oldStorageFilename}.migrated`),
+ true,
+ "The old json storage file name should have been renamed as .migrated"
+ );
+
+ equal(
+ Services.prefs.getBoolPref(
+ `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`,
+ false
+ ),
+ true,
+ `Got the ${IDB_MIGRATED_PREF_BRANCH} preference set to true as expected`
+ );
+
+ assertMigrationHistogramCount("success", 1);
+ assertMigrationHistogramCount("failure", 0);
+
+ assertTelemetryEvents([
+ {
+ method: "migrateResult",
+ value: EXTENSION_ID,
+ extra: {
+ backend: "IndexedDB",
+ data_migrated: "y",
+ has_jsonfile: "y",
+ has_olddata: "y",
+ },
+ },
+ ]);
+
+ equal(
+ Services.prefs.getBoolPref(
+ `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`,
+ false
+ ),
+ true,
+ `${IDB_MIGRATED_PREF_BRANCH} should still be true on keepStorageOnUninstall=true`
+ );
+
+ // Upgrade the extension and check that no telemetry events are being sent
+ // for an already migrated extension.
+ await extension.upgrade({
+ ...extensionDefinition,
+ background,
+ });
+
+ await extension.awaitMessage("storage-local-data-migrated");
+
+ // The histogram values are unmodified.
+ assertMigrationHistogramCount("success", 1);
+ assertMigrationHistogramCount("failure", 0);
+
+ // No new telemetry events recorded for the extension.
+ const snapshot = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ );
+ const filterByCategory = ([timestamp, category]) =>
+ category === EVENT_CATEGORY;
+
+ ok(
+ !snapshot.parent || snapshot.parent.filter(filterByCategory).length === 0,
+ "No telemetry events should be recorded for an already migrated extension"
+ );
+
+ Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, false);
+ Services.prefs.setBoolPref(LEAVE_UUID_PREF, false);
+
+ await extension.unload();
+
+ equal(
+ Services.prefs.getPrefType(`${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`),
+ Services.prefs.PREF_INVALID,
+ `Got the ${IDB_MIGRATED_PREF_BRANCH} preference has been cleared on addon uninstall`
+ );
+});
+
+// Test that the extensionId included in the telemetry event is being trimmed down to 80 chars
+// as expected.
+add_task(async function test_extensionId_trimmed_in_telemetry_event() {
+ // Generated extensionId in email-like format, longer than 80 chars.
+ const EXTENSION_ID = `long.extension.id@${Array(80).fill("a").join("")}`;
+
+ const data = { test_key_string: "test_value" };
+
+ // Store some fake data in the storage.local file backend before starting the extension.
+ await createExtensionJSONFileWithData(EXTENSION_ID, data);
+
+ async function background() {
+ const storedData = await browser.storage.local.get("test_key_string");
+
+ browser.test.assertEq(
+ "test_value",
+ storedData.test_key_string,
+ "Got the expected data after the storage.local data migration"
+ );
+
+ browser.test.sendMessage("storage-local-data-migrated");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ // We don't want the (default) startupReason ADDON_INSTALL because
+ // that automatically sets the migrated pref and skips migration.
+ startupReason: "APP_STARTUP",
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("storage-local-data-migrated");
+
+ const expectedTrimmedExtensionId = getTrimmedString(EXTENSION_ID);
+
+ equal(
+ expectedTrimmedExtensionId.length,
+ 80,
+ "The trimmed version of the extensionId should be 80 chars long"
+ );
+
+ assertTelemetryEvents([
+ {
+ method: "migrateResult",
+ value: expectedTrimmedExtensionId,
+ extra: {
+ backend: "IndexedDB",
+ data_migrated: "y",
+ has_jsonfile: "y",
+ has_olddata: "y",
+ },
+ },
+ ]);
+
+ await extension.unload();
+});
+
+// Test that if the old JSONFile data file is corrupted and the old data
+// can't be successfully migrated to the new storage backend, then:
+// - the new storage backend for that extension is still initialized and enabled
+// - any new data is being stored in the new backend
+// - the old file is being renamed (with the `.corrupted` suffix that JSONFile.sys.mjs
+// adds when it fails to load the data file) and still available on disk.
+add_task(async function test_storage_local_corrupted_data_migration() {
+ const EXTENSION_ID = "extension-corrupted-data-migration@mozilla.org";
+
+ const invalidData = `{"test_key_string": "test_value1"`;
+ const oldStorageFilename = ExtensionStorage.getStorageFile(EXTENSION_ID);
+
+ await IOUtils.makeDirectory(
+ PathUtils.join(PathUtils.profileDir, "browser-extension-data", EXTENSION_ID)
+ );
+
+ // Write the json file with some invalid data.
+ await IOUtils.writeUTF8(oldStorageFilename, invalidData, { flush: true });
+ equal(
+ await IOUtils.readUTF8(oldStorageFilename),
+ invalidData,
+ "The old json file has been overwritten with invalid data"
+ );
+
+ async function background() {
+ const storedData = await browser.storage.local.get();
+
+ browser.test.assertEq(
+ Object.keys(storedData).length,
+ 0,
+ "No data should be found on invalid data migration"
+ );
+
+ await browser.storage.local.set({
+ test_key_string_on_IDBBackend: "expected-value",
+ });
+
+ browser.test.sendMessage("storage-local-data-migrated-and-set");
+ }
+
+ clearMigrationHistogram();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ // We don't want the (default) startupReason ADDON_INSTALL because
+ // that automatically sets the migrated pref and skips migration.
+ startupReason: "APP_STARTUP",
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("storage-local-data-migrated-and-set");
+
+ const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(
+ extension.extension
+ );
+
+ const idbConn = await ExtensionStorageIDB.open(storagePrincipal);
+
+ equal(
+ await idbConn.isEmpty(extension.extension),
+ false,
+ "Data stored in the ExtensionStorageIDB backend as expected"
+ );
+
+ equal(
+ await IOUtils.exists(`${oldStorageFilename}.corrupt`),
+ true,
+ "The old json storage should still be available if failed to be read"
+ );
+
+ // The extension is still migrated successfully to the new backend if the file from the
+ // original json file was corrupted.
+
+ equal(
+ Services.prefs.getBoolPref(
+ `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`,
+ false
+ ),
+ true,
+ `Got the ${IDB_MIGRATED_PREF_BRANCH} preference set to true as expected`
+ );
+
+ assertMigrationHistogramCount("success", 1);
+ assertMigrationHistogramCount("failure", 0);
+
+ assertTelemetryEvents([
+ {
+ method: "migrateResult",
+ value: EXTENSION_ID,
+ extra: {
+ backend: "IndexedDB",
+ data_migrated: "y",
+ has_jsonfile: "y",
+ has_olddata: "n",
+ },
+ },
+ ]);
+
+ await extension.unload();
+});
+
+// Test that if the data migration fails to store the old data into the IndexedDB backend
+// then the expected telemetry histogram is being updated.
+add_task(async function test_storage_local_data_migration_failure() {
+ const EXTENSION_ID = "extension-data-migration-failure@mozilla.org";
+
+ // Create the file under the expected directory tree.
+ const { jsonFile, oldStorageFilename } =
+ await createExtensionJSONFileWithData(EXTENSION_ID, {});
+
+ // Store a fake invalid value which is going to fail to be saved into IndexedDB
+ // (because it can't be cloned and it is going to raise a DataCloneError), which
+ // will trigger a data migration failure that we expect to increment the related
+ // telemetry histogram.
+ jsonFile.data.set("fake_invalid_key", function () {});
+
+ async function background() {
+ await browser.storage.local.set({
+ test_key_string_on_JSONFileBackend: "expected-value",
+ });
+ browser.test.sendMessage("storage-local-data-migrated-and-set");
+ }
+
+ clearMigrationHistogram();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ // We don't want the (default) startupReason ADDON_INSTALL because
+ // that automatically sets the migrated pref and skips migration.
+ startupReason: "APP_STARTUP",
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("storage-local-data-migrated-and-set");
+
+ const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(
+ extension.extension
+ );
+
+ const idbConn = await ExtensionStorageIDB.open(storagePrincipal);
+ equal(
+ await idbConn.isEmpty(extension.extension),
+ true,
+ "No data stored in the ExtensionStorageIDB backend as expected"
+ );
+ equal(
+ await IOUtils.exists(oldStorageFilename),
+ true,
+ "The old json storage should still be available if failed to be read"
+ );
+
+ await extension.unload();
+
+ assertTelemetryEvents([
+ {
+ method: "migrateResult",
+ value: EXTENSION_ID,
+ extra: {
+ backend: "JSONFile",
+ data_migrated: "n",
+ error_name: "DataCloneError",
+ has_jsonfile: "y",
+ has_olddata: "y",
+ },
+ },
+ ]);
+
+ assertMigrationHistogramCount("success", 0);
+ assertMigrationHistogramCount("failure", 1);
+});
+
+add_task(async function test_migration_aborted_on_shutdown() {
+ const EXTENSION_ID = "test-migration-aborted-on-shutdown@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ // We don't want the (default) startupReason ADDON_INSTALL because
+ // that automatically sets the migrated pref and skips migration.
+ startupReason: "APP_STARTUP",
+ });
+
+ await extension.startup();
+
+ equal(
+ extension.extension.hasShutdown,
+ false,
+ "The extension is still running"
+ );
+
+ await extension.unload();
+ equal(extension.extension.hasShutdown, true, "The extension has shutdown");
+
+ // Trigger a data migration after the extension has been unloaded.
+ const result = await ExtensionStorageIDB.selectBackend({
+ extension: extension.extension,
+ });
+ Assert.deepEqual(
+ result,
+ { backendEnabled: false },
+ "Expect migration to have been aborted"
+ );
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ value: EXTENSION_ID,
+ extra: {
+ backend: "JSONFile",
+ error_name: "DataMigrationAbortedError",
+ },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTER
+ );
+});
+
+add_task(async function test_storage_local_data_migration_clear_pref() {
+ Services.prefs.clearUserPref(LEAVE_STORAGE_PREF);
+ Services.prefs.clearUserPref(LEAVE_UUID_PREF);
+ Services.prefs.clearUserPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF);
+ await promiseShutdownManager();
+ await TelemetryController.testShutdown();
+});
+
+add_task(async function setup_quota_manager_testing_prefs() {
+ Services.prefs.setBoolPref("dom.quotaManager.testing", true);
+ Services.prefs.setIntPref(
+ "dom.quotaManager.temporaryStorage.fixedLimit",
+ 100
+ );
+ await promiseQuotaManagerServiceReset();
+});
+
+add_task(
+ // TODO: temporarily disabled because it currently perma-fails on
+ // android builds (Bug 1564871)
+ { skip_if: () => AppConstants.platform === "android" },
+ // eslint-disable-next-line no-use-before-define
+ test_quota_exceeded_while_migrating_data
+);
+async function test_quota_exceeded_while_migrating_data() {
+ const EXT_ID = "test-data-migration-stuck@mochi.test";
+ const dataSize = 1000 * 1024;
+
+ await createExtensionJSONFileWithData(EXT_ID, {
+ data: new Array(dataSize).fill("x").join(""),
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: { gecko: { id: EXT_ID } },
+ },
+ background() {
+ browser.test.onMessage.addListener(async (msg, dataSize) => {
+ if (msg !== "verify-stored-data") {
+ return;
+ }
+ const res = await browser.storage.local.get();
+ browser.test.assertEq(
+ res.data && res.data.length,
+ dataSize,
+ "Got the expected data"
+ );
+ browser.test.sendMessage("verify-stored-data:done");
+ });
+
+ browser.test.sendMessage("bg-page:ready");
+ },
+ // We don't want the (default) startupReason ADDON_INSTALL because
+ // that automatically sets the migrated pref and skips migration.
+ startupReason: "APP_STARTUP",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bg-page:ready");
+
+ extension.sendMessage("verify-stored-data", dataSize);
+ await extension.awaitMessage("verify-stored-data:done");
+
+ await ok(
+ !ExtensionStorageIDB.isMigratedExtension(extension),
+ "The extension falls back to the JSONFile backend because of the migration failure"
+ );
+ await extension.unload();
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ value: EXT_ID,
+ extra: {
+ backend: "JSONFile",
+ error_name: "QuotaExceededError",
+ },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTER
+ );
+
+ Services.prefs.clearUserPref("dom.quotaManager.temporaryStorage.fixedLimit");
+ await promiseQuotaManagerServiceClear();
+ Services.prefs.clearUserPref("dom.quotaManager.testing");
+}
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js
new file mode 100644
index 0000000000..c846494f0c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js
@@ -0,0 +1,83 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_local_cache_invalidation() {
+ function background(checkGet) {
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "set-initial") {
+ await browser.storage.local.set({
+ "test-prop1": "value1",
+ "test-prop2": "value2",
+ });
+ browser.test.sendMessage("set-initial-done");
+ } else if (msg === "check") {
+ await checkGet("local", "test-prop1", "value1");
+ await checkGet("local", "test-prop2", "value2");
+ browser.test.sendMessage("check-done");
+ }
+ });
+
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ },
+ background: `(${background})(${checkGetImpl})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ extension.sendMessage("set-initial");
+ await extension.awaitMessage("set-initial-done");
+
+ Services.obs.notifyObservers(null, "extension-invalidate-storage-cache");
+
+ extension.sendMessage("check");
+ await extension.awaitMessage("check-done");
+
+ await extension.unload();
+});
+
+add_task(function test_storage_local_file_backend() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], () =>
+ test_background_page_storage("local")
+ );
+});
+
+add_task(function test_storage_local_idb_backend() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () =>
+ test_background_page_storage("local")
+ );
+});
+
+add_task(function test_storage_local_idb_bytes_in_use() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () =>
+ test_background_storage_area_no_bytes_in_use("local")
+ );
+});
+
+add_task(function test_storage_local_onChanged_event_page() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () =>
+ test_storage_change_event_page("local")
+ );
+});
+
+add_task(async function test_storage_local_empty_events() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () =>
+ test_storage_empty_events("local")
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js
new file mode 100644
index 0000000000..5bf6dcc3bc
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js
@@ -0,0 +1,212 @@
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ MockRegistry: "resource://testing-common/MockRegistry.sys.mjs",
+});
+
+const MANIFEST = {
+ name: "test-storage-managed@mozilla.com",
+ description: "",
+ type: "storage",
+ data: {
+ null: null,
+ str: "hello",
+ obj: {
+ a: [2, 3],
+ b: true,
+ },
+ },
+};
+
+AddonTestUtils.init(this);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+
+ let tmpDir = FileUtils.getDir("TmpD", ["native-manifests"]);
+ tmpDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ let dirProvider = {
+ getFile(property) {
+ if (property.endsWith("NativeManifests")) {
+ return tmpDir.clone();
+ }
+ },
+ };
+ Services.dirsvc.registerProvider(dirProvider);
+
+ let typeSlug =
+ AppConstants.platform === "linux" ? "managed-storage" : "ManagedStorage";
+ await IOUtils.makeDirectory(PathUtils.join(tmpDir.path, typeSlug));
+
+ let path = PathUtils.join(tmpDir.path, typeSlug, `${MANIFEST.name}.json`);
+ await IOUtils.writeJSON(path, MANIFEST);
+
+ let registry;
+ if (AppConstants.platform === "win") {
+ registry = new MockRegistry();
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `Software\\\Mozilla\\\ManagedStorage\\${MANIFEST.name}`,
+ "",
+ path
+ );
+ }
+
+ registerCleanupFunction(() => {
+ Services.dirsvc.unregisterProvider(dirProvider);
+ tmpDir.remove(true);
+ if (registry) {
+ registry.shutdown();
+ }
+ });
+});
+
+add_task(async function test_storage_managed() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: MANIFEST.name } },
+ permissions: ["storage"],
+ },
+
+ async background() {
+ await browser.test.assertRejects(
+ browser.storage.managed.set({ a: 1 }),
+ /storage.managed is read-only/,
+ "browser.storage.managed.set() rejects because it's read only"
+ );
+
+ await browser.test.assertRejects(
+ browser.storage.managed.remove("str"),
+ /storage.managed is read-only/,
+ "browser.storage.managed.remove() rejects because it's read only"
+ );
+
+ await browser.test.assertRejects(
+ browser.storage.managed.clear(),
+ /storage.managed is read-only/,
+ "browser.storage.managed.clear() rejects because it's read only"
+ );
+
+ browser.test.sendMessage(
+ "results",
+ await Promise.all([
+ browser.storage.managed.get(),
+ browser.storage.managed.get("str"),
+ browser.storage.managed.get(["null", "obj"]),
+ browser.storage.managed.get({ str: "a", num: 2 }),
+ ])
+ );
+ },
+ });
+
+ await extension.startup();
+ deepEqual(await extension.awaitMessage("results"), [
+ MANIFEST.data,
+ { str: "hello" },
+ { null: null, obj: MANIFEST.data.obj },
+ { str: "hello", num: 2 },
+ ]);
+ await extension.unload();
+});
+
+add_task(async function test_storage_managed_from_content_script() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: MANIFEST.name } },
+ permissions: ["storage"],
+ content_scripts: [
+ {
+ js: ["contentscript.js"],
+ matches: ["*://*/*"],
+ run_at: "document_end",
+ },
+ ],
+ },
+
+ files: {
+ "contentscript.js": async function () {
+ browser.test.sendMessage(
+ "results",
+ await browser.storage.managed.get()
+ );
+ },
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+ deepEqual(await extension.awaitMessage("results"), MANIFEST.data);
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_manifest_not_found() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ },
+
+ async background() {
+ await browser.test.assertRejects(
+ browser.storage.managed.get({ a: 1 }),
+ /Managed storage manifest not found/,
+ "browser.storage.managed.get() rejects when without manifest"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_manifest_not_found() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ },
+
+ async background() {
+ const dummyListener = () => {};
+ browser.storage.managed.onChanged.addListener(dummyListener);
+ browser.test.assertTrue(
+ browser.storage.managed.onChanged.hasListener(dummyListener),
+ "addListener works according to hasListener"
+ );
+ browser.storage.managed.onChanged.removeListener(dummyListener);
+
+ // We should get a warning for each registration.
+ browser.storage.managed.onChanged.addListener(() => {});
+ browser.storage.managed.onChanged.addListener(() => {});
+ browser.storage.managed.onChanged.addListener(() => {});
+
+ // Invoke the storage.managed API to make sure that we have made a
+ // round trip to the parent process and back. This is because event
+ // registration is async but we cannot await (bug 1300234).
+ await browser.test.assertRejects(
+ browser.storage.managed.get({ a: 1 }),
+ /Managed storage manifest not found/,
+ "browser.storage.managed.get() rejects when without manifest"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+ });
+ const UNSUP_EVENT_WARNING = `attempting to use listener "storage.managed.onChanged", which is unimplemented`;
+ messages = messages.filter(msg => msg.message.includes(UNSUP_EVENT_WARNING));
+ Assert.equal(messages.length, 4, "Expected msg for each addListener call");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js
new file mode 100644
index 0000000000..169bef4139
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js
@@ -0,0 +1,44 @@
+"use strict";
+
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+// Load policy engine
+Services.policies; // eslint-disable-line no-unused-expressions
+
+AddonTestUtils.init(this);
+
+add_task(async function test_storage_managed_policy() {
+ await ExtensionTestUtils.startAddonManager();
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ "3rdparty": {
+ Extensions: {
+ "test-storage-managed-policy@mozilla.com": {
+ string: "value",
+ },
+ },
+ },
+ },
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "test-storage-managed-policy@mozilla.com" },
+ },
+ permissions: ["storage"],
+ },
+
+ async background() {
+ let str = await browser.storage.managed.get("string");
+ browser.test.sendMessage("results", str);
+ },
+ });
+
+ await extension.startup();
+ deepEqual(await extension.awaitMessage("results"), { string: "value" });
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js
new file mode 100644
index 0000000000..b03646d939
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js
@@ -0,0 +1,80 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs",
+});
+
+const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall";
+const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall";
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ // Ensure that the IDB backend is enabled.
+ Services.prefs.setBoolPref("ExtensionStorageIDB.BACKEND_ENABLED_PREF", true);
+
+ Services.prefs.setBoolPref("dom.quotaManager.testing", true);
+ Services.prefs.setIntPref(
+ "dom.quotaManager.temporaryStorage.fixedLimit",
+ 100
+ );
+ await promiseQuotaManagerServiceReset();
+
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_storage_local_set_quota_exceeded_error() {
+ const EXT_ID = "test-quota-exceeded@mochi.test";
+
+ const extensionDef = {
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: { gecko: { id: EXT_ID } },
+ },
+ async background() {
+ const data = new Array(1000 * 1024).fill("x").join("");
+ await browser.test.assertRejects(
+ browser.storage.local.set({ data }),
+ /QuotaExceededError/,
+ "Got a rejection with the expected error message"
+ );
+ browser.test.sendMessage("data-stored");
+ },
+ };
+
+ Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true);
+ Services.prefs.setBoolPref(LEAVE_UUID_PREF, true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(LEAVE_STORAGE_PREF);
+ Services.prefs.clearUserPref(LEAVE_UUID_PREF);
+ });
+
+ const extension = ExtensionTestUtils.loadExtension(extensionDef);
+
+ // Run test on a test extension being migrated to the IDB backend.
+ await extension.startup();
+ await extension.awaitMessage("data-stored");
+
+ ok(
+ ExtensionStorageIDB.isMigratedExtension(extension),
+ "The extension has been successfully migrated to the IDB backend"
+ );
+ await extension.unload();
+
+ // Run again on a test extension already already migrated to the IDB backend.
+ const extensionUpdated = ExtensionTestUtils.loadExtension(extensionDef);
+ await extensionUpdated.startup();
+ ok(
+ ExtensionStorageIDB.isMigratedExtension(extension),
+ "The extension has been successfully migrated to the IDB backend"
+ );
+ await extensionUpdated.awaitMessage("data-stored");
+
+ await extensionUpdated.unload();
+
+ Services.prefs.clearUserPref("dom.quotaManager.temporaryStorage.fixedLimit");
+ await promiseQuotaManagerServiceClear();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js
new file mode 100644
index 0000000000..6c69ad1a4c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js
@@ -0,0 +1,107 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Sanitizer: "resource:///modules/Sanitizer.sys.mjs",
+});
+
+async function test_sanitize_offlineApps(storageHelpersScript) {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ background: {
+ scripts: ["storageHelpers.js", "background.js"],
+ },
+ },
+ files: {
+ "storageHelpers.js": storageHelpersScript,
+ "background.js": function () {
+ browser.test.onMessage.addListener(async (msg, args) => {
+ let result = {};
+ switch (msg) {
+ case "set-storage-data":
+ await window.testWriteKey(...args);
+ break;
+ case "get-storage-data":
+ const value = await window.testReadKey(args[0]);
+ browser.test.assertEq(args[1], value, "Got the expected value");
+ break;
+ default:
+ browser.test.fail(`Unexpected test message received: ${msg}`);
+ }
+
+ browser.test.sendMessage(`${msg}:done`, result);
+ });
+ },
+ },
+ });
+
+ await extension.startup();
+
+ extension.sendMessage("set-storage-data", ["aKey", "aValue"]);
+ await extension.awaitMessage("set-storage-data:done");
+
+ await extension.sendMessage("get-storage-data", ["aKey", "aValue"]);
+ await extension.awaitMessage("get-storage-data:done");
+
+ info("Verify the extension data not cleared by offlineApps Sanitizer");
+ await Sanitizer.sanitize(["offlineApps"]);
+ await extension.sendMessage("get-storage-data", ["aKey", "aValue"]);
+ await extension.awaitMessage("get-storage-data:done");
+
+ await extension.unload();
+}
+
+add_task(async function test_sanitize_offlineApps_extension_indexedDB() {
+ await test_sanitize_offlineApps(function indexedDBStorageHelpers() {
+ const getIDBStore = () =>
+ new Promise(resolve => {
+ let dbreq = window.indexedDB.open("TestDB");
+ dbreq.onupgradeneeded = () =>
+ dbreq.result.createObjectStore("TestStore");
+ dbreq.onsuccess = () => resolve(dbreq.result);
+ });
+
+ // Export writeKey and readKey storage test helpers.
+ window.testWriteKey = (k, v) =>
+ getIDBStore().then(db => {
+ const tx = db.transaction("TestStore", "readwrite");
+ const store = tx.objectStore("TestStore");
+ return new Promise((resolve, reject) => {
+ tx.oncomplete = evt => resolve(evt.target.result);
+ tx.onerror = evt => reject(evt.target.error);
+ store.add(v, k);
+ });
+ });
+ window.testReadKey = k =>
+ getIDBStore().then(db => {
+ const tx = db.transaction("TestStore");
+ const store = tx.objectStore("TestStore");
+ return new Promise((resolve, reject) => {
+ const req = store.get(k);
+ tx.oncomplete = evt => resolve(req.result);
+ tx.onerror = evt => reject(evt.target.error);
+ });
+ });
+ });
+});
+
+add_task(
+ {
+ // Skip this test if LSNG is not enabled (because this test is only
+ // going to pass when nextgen local storage is being used).
+ skip_if: () =>
+ Services.prefs.getBoolPref(
+ "dom.storage.enable_unsupported_legacy_implementation"
+ ),
+ },
+ async function test_sanitize_offlineApps_extension_localStorage() {
+ await test_sanitize_offlineApps(function indexedDBStorageHelpers() {
+ // Export writeKey and readKey storage test helpers.
+ window.testWriteKey = (k, v) => window.localStorage.setItem(k, v);
+ window.testReadKey = k => window.localStorage.getItem(k);
+ });
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_session.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_session.js
new file mode 100644
index 0000000000..9dc0aa5af9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_session.js
@@ -0,0 +1,97 @@
+"use strict";
+
+AddonTestUtils.init(this);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_setup(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_storage_session() {
+ await test_background_page_storage("session");
+});
+
+add_task(async function test_storage_session_onChanged_event_page() {
+ await test_storage_change_event_page("session");
+});
+
+add_task(async function test_storage_session_persistance() {
+ await test_storage_after_reload("session", { expectPersistency: false });
+});
+
+add_task(async function test_storage_session_empty_events() {
+ await test_storage_empty_events("session");
+});
+
+add_task(async function test_storage_session_contentscript() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ permissions: ["storage"],
+ },
+ background() {
+ let events = [];
+ browser.storage.onChanged.addListener((_, area) => {
+ events.push(area);
+ });
+ browser.test.onMessage.addListener(_msg => {
+ browser.test.sendMessage("bg-events", events.join());
+ });
+ browser.runtime.onMessage.addListener(async _msg => {
+ await browser.storage.local.set({ foo: "local" });
+ await browser.storage.session.set({ foo: "session" });
+ await browser.storage.sync.set({ foo: "sync" });
+ browser.test.sendMessage("done");
+ });
+ },
+ files: {
+ "content_script.js"() {
+ let events = [];
+ browser.storage.onChanged.addListener((_, area) => {
+ events.push(area);
+ });
+ browser.test.onMessage.addListener(_msg => {
+ browser.test.sendMessage("cs-events", events.join());
+ });
+
+ browser.test.assertEq(
+ typeof browser.storage.session,
+ "undefined",
+ "Expect storage.session to not be available in content scripts"
+ );
+ browser.runtime.sendMessage("ready");
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ await extension.awaitMessage("done");
+ extension.sendMessage("_getEvents");
+
+ equal(
+ "local,sync",
+ await extension.awaitMessage("cs-events"),
+ "Content script doesn't see storage.onChanged events from the session area."
+ );
+ equal(
+ "local,session,sync",
+ await extension.awaitMessage("bg-events"),
+ "Background receives onChanged events from all storage areas."
+ );
+
+ await extension.unload();
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js
new file mode 100644
index 0000000000..e28af80d0a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js
@@ -0,0 +1,35 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false);
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(test_config_flag_needed);
+
+add_task(test_sync_reloading_extensions_works);
+
+add_task(function test_storage_sync() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_background_page_storage("sync")
+ );
+});
+
+add_task(test_storage_sync_requires_real_id);
+
+add_task(function test_bytes_in_use() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_background_storage_area_with_bytes_in_use("sync", true)
+ );
+});
+
+add_task(function test_storage_onChanged_event_page() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_storage_change_event_page("sync")
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js
new file mode 100644
index 0000000000..7e92eb862c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js
@@ -0,0 +1,2318 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This is a kinto-specific test...
+Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true);
+
+do_get_profile(); // so we can use FxAccounts
+
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+const { CommonUtils } = ChromeUtils.importESModule(
+ "resource://services-common/utils.sys.mjs"
+);
+const {
+ ExtensionStorageSyncKinto: ExtensionStorageSync,
+ KintoStorageTestUtils: {
+ cleanUpForContext,
+ CollectionKeyEncryptionRemoteTransformer,
+ CryptoCollection,
+ idToKey,
+ keyToId,
+ KeyRingEncryptionRemoteTransformer,
+ },
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs"
+);
+const { BulkKeyBundle } = ChromeUtils.importESModule(
+ "resource://services-sync/keys.sys.mjs"
+);
+const { FxAccountsKeys } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsKeys.sys.mjs"
+);
+const { Utils } = ChromeUtils.importESModule(
+ "resource://services-sync/util.sys.mjs"
+);
+
+const { createAppInfo, promiseStartupManager } = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "69");
+
+function handleCannedResponse(cannedResponse, request, response) {
+ response.setStatusLine(
+ null,
+ cannedResponse.status.status,
+ cannedResponse.status.statusText
+ );
+ // send the headers
+ for (let headerLine of cannedResponse.sampleHeaders) {
+ let headerElements = headerLine.split(":");
+ response.setHeader(headerElements[0], headerElements[1].trimLeft());
+ }
+ response.setHeader("Date", new Date().toUTCString());
+
+ response.write(cannedResponse.responseBody);
+}
+
+function collectionPath(collectionId) {
+ return `/buckets/default/collections/${collectionId}`;
+}
+
+function collectionRecordsPath(collectionId) {
+ return `/buckets/default/collections/${collectionId}/records`;
+}
+
+class KintoServer {
+ constructor() {
+ // Set up an HTTP Server
+ this.httpServer = new HttpServer();
+ this.httpServer.start(-1);
+
+ // Set<Object> corresponding to records that might be served.
+ // The format of these objects is defined in the documentation for #addRecord.
+ this.records = [];
+
+ // Collections that we have set up access to (see `installCollection`).
+ this.collections = new Set();
+
+ // ETag to serve with responses
+ this.etag = 1;
+
+ this.port = this.httpServer.identity.primaryPort;
+
+ // POST requests we receive from the client go here
+ this.posts = [];
+ // DELETEd buckets will go here.
+ this.deletedBuckets = [];
+ // Anything in here will force the next POST to generate a conflict
+ this.conflicts = [];
+ // If this is true, reject the next request with a 401
+ this.rejectNextAuthResponse = false;
+ this.failedAuths = [];
+
+ this.installConfigPath();
+ this.installBatchPath();
+ this.installCatchAll();
+ }
+
+ clearPosts() {
+ this.posts = [];
+ }
+
+ getPosts() {
+ return this.posts;
+ }
+
+ getDeletedBuckets() {
+ return this.deletedBuckets;
+ }
+
+ rejectNextAuthWith(response) {
+ this.rejectNextAuthResponse = response;
+ }
+
+ checkAuth(request, response) {
+ equal(request.getHeader("Authorization"), "Bearer some-access-token");
+
+ if (this.rejectNextAuthResponse) {
+ response.setStatusLine(null, 401, "Unauthorized");
+ response.write(this.rejectNextAuthResponse);
+ this.rejectNextAuthResponse = false;
+ this.failedAuths.push(request);
+ return true;
+ }
+ return false;
+ }
+
+ installConfigPath() {
+ const configPath = "/v1/";
+ const responseBody = JSON.stringify({
+ settings: { batch_max_requests: 25 },
+ url: `http://localhost:${this.port}/v1/`,
+ documentation: "https://kinto.readthedocs.org/",
+ version: "1.5.1",
+ commit: "cbc6f58",
+ hello: "kinto",
+ });
+ const configResponse = {
+ sampleHeaders: [
+ "Access-Control-Allow-Origin: *",
+ "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+ "Content-Type: application/json; charset=UTF-8",
+ "Server: waitress",
+ ],
+ status: { status: 200, statusText: "OK" },
+ responseBody: responseBody,
+ };
+
+ function handleGetConfig(request, response) {
+ if (request.method != "GET") {
+ dump(`ARGH, got ${request.method}\n`);
+ }
+ return handleCannedResponse(configResponse, request, response);
+ }
+
+ this.httpServer.registerPathHandler(configPath, handleGetConfig);
+ }
+
+ installBatchPath() {
+ const batchPath = "/v1/batch";
+
+ function handlePost(request, response) {
+ if (this.checkAuth(request, response)) {
+ return;
+ }
+
+ let bodyStr = CommonUtils.readBytesFromInputStream(
+ request.bodyInputStream
+ );
+ let body = JSON.parse(bodyStr);
+ let defaults = body.defaults;
+ for (let req of body.requests) {
+ let headers = Object.assign(
+ {},
+ (defaults && defaults.headers) || {},
+ req.headers
+ );
+ this.posts.push(Object.assign({}, req, { headers }));
+ }
+
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader("Content-Type", "application/json; charset=UTF-8");
+ response.setHeader("Date", new Date().toUTCString());
+
+ let postResponse = {
+ responses: body.requests.map(req => {
+ let oneBody;
+ if (req.method == "DELETE") {
+ let id = req.path.match(
+ /^\/buckets\/default\/collections\/.+\/records\/(.+)$/
+ )[1];
+ oneBody = {
+ data: {
+ deleted: true,
+ id: id,
+ last_modified: this.etag,
+ },
+ };
+ } else {
+ oneBody = {
+ data: Object.assign({}, req.body.data, {
+ last_modified: this.etag,
+ }),
+ permissions: [],
+ };
+ }
+
+ return {
+ path: req.path,
+ status: 201, // FIXME -- only for new posts??
+ headers: { ETag: 3000 }, // FIXME???
+ body: oneBody,
+ };
+ }),
+ };
+
+ if (this.conflicts.length) {
+ const nextConflict = this.conflicts.shift();
+ if (!nextConflict.transient) {
+ this.records.push(nextConflict);
+ }
+ const { data } = nextConflict;
+ postResponse = {
+ responses: body.requests.map(req => {
+ return {
+ path: req.path,
+ status: 412,
+ headers: { ETag: this.etag }, // is this correct??
+ body: {
+ details: {
+ existing: data,
+ },
+ },
+ };
+ }),
+ };
+ }
+
+ response.write(JSON.stringify(postResponse));
+
+ // "sampleHeaders": [
+ // "Access-Control-Allow-Origin: *",
+ // "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+ // "Server: waitress",
+ // "Etag: \"4000\""
+ // ],
+ }
+
+ this.httpServer.registerPathHandler(batchPath, handlePost.bind(this));
+ }
+
+ installCatchAll() {
+ this.httpServer.registerPathHandler("/", (request, response) => {
+ dump(
+ `got request: ${request.method}:${request.path}?${request.queryString}\n`
+ );
+ dump(
+ `${CommonUtils.readBytesFromInputStream(request.bodyInputStream)}\n`
+ );
+ });
+ }
+
+ /**
+ * Add a record to those that can be served by this server.
+ *
+ * @param {object} properties An object describing the record that
+ * should be served. The properties of this object are:
+ * - collectionId {string} This record should only be served if a
+ * request is for this collection.
+ * - predicate {Function} If present, this record should only be served if the
+ * predicate returns true. The predicate will be called with
+ * {request: Request, response: Response, since: number, server: KintoServer}.
+ * - data {string} The record to serve.
+ * - conflict {boolean} If present and true, this record is added to
+ * "conflicts" and won't be served, but will cause a conflict on
+ * the next push.
+ */
+ addRecord(properties) {
+ if (!properties.conflict) {
+ this.records.push(properties);
+ } else {
+ this.conflicts.push(properties);
+ }
+
+ this.installCollection(properties.collectionId);
+ }
+
+ /**
+ * Tell the server to set up a route for this collection.
+ *
+ * This will automatically be called for any collection to which you `addRecord`.
+ *
+ * @param {string} collectionId the collection whose route we
+ * should set up.
+ */
+ installCollection(collectionId) {
+ if (this.collections.has(collectionId)) {
+ return;
+ }
+ this.collections.add(collectionId);
+ const remoteCollectionPath =
+ "/v1" + collectionPath(encodeURIComponent(collectionId));
+ this.httpServer.registerPathHandler(
+ remoteCollectionPath,
+ this.handleGetCollection.bind(this, collectionId)
+ );
+ const remoteRecordsPath =
+ "/v1" + collectionRecordsPath(encodeURIComponent(collectionId));
+ this.httpServer.registerPathHandler(
+ remoteRecordsPath,
+ this.handleGetRecords.bind(this, collectionId)
+ );
+ }
+
+ handleGetCollection(collectionId, request, response) {
+ if (this.checkAuth(request, response)) {
+ return;
+ }
+
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader("Content-Type", "application/json; charset=UTF-8");
+ response.setHeader("Date", new Date().toUTCString());
+ response.write(
+ JSON.stringify({
+ data: {
+ id: collectionId,
+ },
+ })
+ );
+ }
+
+ handleGetRecords(collectionId, request, response) {
+ if (this.checkAuth(request, response)) {
+ return;
+ }
+
+ if (request.method != "GET") {
+ do_throw(`only GET is supported on ${request.path}`);
+ }
+
+ let sinceMatch = request.queryString.match(/(^|&)_since=(\d+)/);
+ let since = sinceMatch && parseInt(sinceMatch[2], 10);
+
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader("Content-Type", "application/json; charset=UTF-8");
+ response.setHeader("Date", new Date().toUTCString());
+ response.setHeader("ETag", this.etag.toString());
+
+ const records = this.records
+ .filter(properties => {
+ if (properties.collectionId != collectionId) {
+ return false;
+ }
+
+ if (properties.predicate) {
+ const predAllowed = properties.predicate({
+ request: request,
+ response: response,
+ since: since,
+ server: this,
+ });
+ if (!predAllowed) {
+ return false;
+ }
+ }
+
+ return true;
+ })
+ .map(properties => properties.data);
+
+ const body = JSON.stringify({
+ data: records,
+ });
+ response.write(body);
+ }
+
+ installDeleteBucket() {
+ this.httpServer.registerPrefixHandler(
+ "/v1/buckets/",
+ (request, response) => {
+ if (request.method != "DELETE") {
+ dump(
+ `got a non-delete action on bucket: ${request.method} ${request.path}\n`
+ );
+ return;
+ }
+
+ const noPrefix = request.path.slice("/v1/buckets/".length);
+ const [bucket, afterBucket] = noPrefix.split("/", 1);
+ if (afterBucket && afterBucket != "") {
+ dump(
+ `got a delete for a non-bucket: ${request.method} ${request.path}\n`
+ );
+ }
+
+ this.deletedBuckets.push(bucket);
+ // Fake like this actually deletes the records.
+ this.records = [];
+
+ response.write(
+ JSON.stringify({
+ data: {
+ deleted: true,
+ last_modified: 1475161309026,
+ id: "b09f1618-d789-302d-696e-74ec53ee18a8", // FIXME
+ },
+ })
+ );
+ }
+ );
+ }
+
+ // Utility function to install a keyring at the start of a test.
+ async installKeyRing(fxaService, keysData, salts, etag, properties) {
+ const keysRecord = {
+ id: "keys",
+ keys: keysData,
+ salts: salts,
+ last_modified: etag,
+ };
+ this.etag = etag;
+ const transformer = new KeyRingEncryptionRemoteTransformer(fxaService);
+ return this.encryptAndAddRecord(
+ transformer,
+ Object.assign({}, properties, {
+ collectionId: "storage-sync-crypto",
+ data: keysRecord,
+ })
+ );
+ }
+
+ encryptAndAddRecord(transformer, properties) {
+ return transformer.encode(properties.data).then(encrypted => {
+ this.addRecord(Object.assign({}, properties, { data: encrypted }));
+ });
+ }
+
+ stop() {
+ this.httpServer.stop(() => {});
+ }
+}
+
+/**
+ * Predicate that represents a record appearing at some time.
+ * Requests with "_since" before this time should see this record,
+ * unless the server itself isn't at this time yet (etag is before
+ * this time).
+ *
+ * Requests with _since after this time shouldn't see this record any
+ * more, since it hasn't changed after this time.
+ *
+ * @param {int} startTime the etag at which time this record should
+ * start being available (and thus, the predicate should start
+ * returning true)
+ * @returns {Function}
+ */
+function appearsAt(startTime) {
+ return function ({ since, server }) {
+ return since < startTime && startTime < server.etag;
+ };
+}
+
+// Run a block of code with access to a KintoServer.
+async function withServer(f) {
+ let server = new KintoServer();
+ // Point the sync.storage client to use the test server we've just started.
+ Services.prefs.setCharPref(
+ "webextensions.storage.sync.serverURL",
+ `http://localhost:${server.port}/v1`
+ );
+ try {
+ await f(server);
+ } finally {
+ server.stop();
+ }
+}
+
+// Run a block of code with access to both a sync context and a
+// KintoServer. This is meant as a workaround for eslint's refusal to
+// let me have 5 nested callbacks.
+async function withContextAndServer(f) {
+ await withSyncContext(async function (context) {
+ await withServer(async function (server) {
+ await f(context, server);
+ });
+ });
+}
+
+// Run a block of code with fxa mocked out to return a specific user.
+// Calls the given function with an ExtensionStorageSync instance that
+// was constructed using a mocked FxAccounts instance.
+async function withSignedInUser(user, f) {
+ let fxaServiceMock = {
+ getSignedInUser() {
+ return Promise.resolve({ uid: user.uid });
+ },
+ getOAuthToken() {
+ return Promise.resolve("some-access-token");
+ },
+ checkAccountStatus() {
+ return Promise.resolve(true);
+ },
+ removeCachedOAuthToken() {
+ return Promise.resolve();
+ },
+ keys: {
+ getKeyForScope(scope) {
+ return Promise.resolve({ ...user.scopedKeys[scope] });
+ },
+ kidAsHex(jwk) {
+ return new FxAccountsKeys({}).kidAsHex(jwk);
+ },
+ },
+ };
+
+ let telemetryMock = {
+ _calls: [],
+ _histograms: {},
+ scalarSet(name, value) {
+ this._calls.push({ method: "scalarSet", name, value });
+ },
+ keyedScalarSet(name, key, value) {
+ this._calls.push({ method: "keyedScalarSet", name, key, value });
+ },
+ getKeyedHistogramById(name) {
+ let self = this;
+ return {
+ add(key, value) {
+ if (!self._histograms[name]) {
+ self._histograms[name] = [];
+ }
+ self._histograms[name].push(value);
+ },
+ };
+ },
+ };
+ let extensionStorageSync = new ExtensionStorageSync(
+ fxaServiceMock,
+ telemetryMock
+ );
+ await f(extensionStorageSync, fxaServiceMock);
+}
+
+// Some assertions that make it easier to write tests about what was
+// posted and when.
+
+// Assert that a post in a batch was made with the correct access token.
+// This should be true of all requests, so this is usually called from
+// another assertion.
+function assertAuthenticatedPost(post) {
+ equal(post.headers.Authorization, "Bearer some-access-token");
+}
+
+// Assert that this post was made with the correct request headers to
+// create a new resource while protecting against someone else
+// creating it at the same time (in other words, "If-None-Match: *").
+// Also calls assertAuthenticatedPost(post).
+function assertPostedNewRecord(post) {
+ assertAuthenticatedPost(post);
+ equal(post.headers["If-None-Match"], "*");
+}
+
+// Assert that this post was made with the correct request headers to
+// update an existing resource while protecting against concurrent
+// modification (in other words, `If-Match: "${etag}"`).
+// Also calls assertAuthenticatedPost(post).
+function assertPostedUpdatedRecord(post, since) {
+ assertAuthenticatedPost(post);
+ equal(post.headers["If-Match"], `"${since}"`);
+}
+
+// Assert that this post was an encrypted keyring, and produce the
+// decrypted body. Sanity check the body while we're here.
+const assertPostedEncryptedKeys = async function (fxaService, post) {
+ equal(post.path, collectionRecordsPath("storage-sync-crypto") + "/keys");
+
+ let body = await new KeyRingEncryptionRemoteTransformer(fxaService).decode(
+ post.body.data
+ );
+ ok(body.keys, `keys object should be present in decoded body`);
+ ok(body.keys.default, `keys object should have a default key`);
+ ok(body.salts, `salts object should be present in decoded body`);
+ return body;
+};
+
+// assertEqual, but for keyring[extensionId] == key.
+function assertKeyRingKey(keyRing, extensionId, expectedKey, message) {
+ if (!message) {
+ message = `expected keyring's key for ${extensionId} to match ${expectedKey.keyPairB64}`;
+ }
+ ok(
+ keyRing.hasKeysFor([extensionId]),
+ `expected keyring to have a key for ${extensionId}\n`
+ );
+ deepEqual(
+ keyRing.keyForCollection(extensionId).keyPairB64,
+ expectedKey.keyPairB64,
+ message
+ );
+}
+
+// Assert that this post was posted for a given extension.
+const assertExtensionRecord = async function (
+ fxaService,
+ post,
+ extension,
+ key
+) {
+ const extensionId = extension.id;
+ const cryptoCollection = new CryptoCollection(fxaService);
+ const hashedId =
+ "id-" +
+ (await cryptoCollection.hashWithExtensionSalt(keyToId(key), extensionId));
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ const transformer = new CollectionKeyEncryptionRemoteTransformer(
+ cryptoCollection,
+ await cryptoCollection.getKeyRing(),
+ extensionId
+ );
+ equal(
+ post.path,
+ `${collectionRecordsPath(collectionId)}/${hashedId}`,
+ "decrypted data should be posted to path corresponding to its key"
+ );
+ let decoded = await transformer.decode(post.body.data);
+ equal(
+ decoded.key,
+ key,
+ "decrypted data should have a key attribute corresponding to the extension data key"
+ );
+ return decoded;
+};
+
+// Tests using this ID will share keys in local storage, so be careful.
+const defaultExtensionId = "{13bdde76-4dc7-11e6-9bdc-54ee758d6342}";
+const defaultExtension = { id: defaultExtensionId };
+
+const loggedInUser = {
+ uid: "0123456789abcdef0123456789abcdef",
+ scopedKeys: {
+ "sync:addon_storage": {
+ kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAA",
+ k: "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMQ",
+ kty: "oct",
+ },
+ },
+ oauthTokens: {
+ "sync:addon_storage": {
+ token: "some-access-token",
+ },
+ },
+};
+
+function uuid() {
+ const uuidgen = Services.uuid;
+ return uuidgen.generateUUID().toString();
+}
+
+add_task(async function test_setup() {
+ await promiseStartupManager();
+});
+
+add_task(async function test_single_initialization() {
+ // Check if we're calling openConnection too often.
+ const { FirefoxAdapter } = ChromeUtils.importESModule(
+ "resource://services-common/kinto-storage-adapter.sys.mjs"
+ );
+ const origOpenConnection = FirefoxAdapter.openConnection;
+ let callCount = 0;
+ FirefoxAdapter.openConnection = function (...args) {
+ ++callCount;
+ return origOpenConnection.apply(this, args);
+ };
+ function background() {
+ let promises = ["foo", "bar", "baz", "quux"].map(key =>
+ browser.storage.sync.get(key)
+ );
+ Promise.all(promises).then(() =>
+ browser.test.notifyPass("initialize once")
+ );
+ }
+ try {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ },
+ background: `(${background})()`,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("initialize once");
+ await extension.unload();
+ equal(
+ callCount,
+ 1,
+ "Initialized FirefoxAdapter connection and Kinto exactly once"
+ );
+ } finally {
+ FirefoxAdapter.openConnection = origOpenConnection;
+ }
+});
+
+add_task(async function test_key_to_id() {
+ equal(keyToId("foo"), "key-foo");
+ equal(keyToId("my-new-key"), "key-my_2D_new_2D_key");
+ equal(keyToId(""), "key-");
+ equal(keyToId("™"), "key-_2122_");
+ equal(keyToId("\b"), "key-_8_");
+ equal(keyToId("abc\ndef"), "key-abc_A_def");
+ equal(keyToId("Kinto's fancy_string"), "key-Kinto_27_s_20_fancy_5F_string");
+
+ const KEYS = ["foo", "my-new-key", "", "Kinto's fancy_string", "™", "\b"];
+ for (let key of KEYS) {
+ equal(idToKey(keyToId(key)), key);
+ }
+
+ equal(idToKey("hi"), null);
+ equal(idToKey("-key-hi"), null);
+ equal(idToKey("key--abcd"), null);
+ equal(idToKey("key-%"), null);
+ equal(idToKey("key-_HI"), null);
+ equal(idToKey("key-_HI_"), null);
+ equal(idToKey("key-"), "");
+ equal(idToKey("key-1"), "1");
+ equal(idToKey("key-_2D_"), "-");
+});
+
+add_task(async function test_extension_id_to_collection_id() {
+ const extensionId = "{9419cce6-5435-11e6-84bf-54ee758d6342}";
+ // FIXME: this doesn't actually require the signed in user, but the
+ // extensionIdToCollectionId method exists on CryptoCollection,
+ // which needs an fxaService to be instantiated.
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ // Fake a static keyring since the server doesn't exist.
+ const salt = "Scgx8RJ8Y0rxMGFYArUiKeawlW+0zJyFmtTDvro9qPo=";
+ const cryptoCollection = new CryptoCollection(fxaService);
+ await cryptoCollection._setSalt(extensionId, salt);
+
+ equal(
+ await cryptoCollection.extensionIdToCollectionId(extensionId),
+ "ext-0_QHA1P93_yJoj7ONisrR0lW6uN4PZ3Ii-rT-QOjtvo"
+ );
+ }
+ );
+});
+
+add_task(async function ensureCanSync_clearAll() {
+ // A test extension that will not have any active context around
+ // but it is returned from a call to AddonManager.getExtensionsByType.
+ const extensionId = "test-wipe-on-enabled-and-synced@mochi.test";
+ const testExtension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: { gecko: { id: extensionId } },
+ },
+ });
+
+ await testExtension.startup();
+
+ // Retrieve the Extension class instance from the test extension.
+ const { extension } = testExtension;
+
+ // Another test extension that will have an active extension context.
+ const extensionId2 = "test-wipe-on-active-context@mochi.test";
+ const extension2 = { id: extensionId2 };
+
+ await withContextAndServer(async function (context, server) {
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ async function assertSetAndGetData(extension, data) {
+ await extensionStorageSync.set(extension, data, context);
+ let storedData = await extensionStorageSync.get(
+ extension,
+ Object.keys(data),
+ context
+ );
+ const extId = extensionId;
+ deepEqual(
+ storedData,
+ data,
+ `${extId} should get back the data we set`
+ );
+ }
+
+ async function assertDataCleared(extension, keys) {
+ const storedData = await extensionStorageSync.get(
+ extension,
+ keys,
+ context
+ );
+ deepEqual(
+ storedData,
+ {},
+ `${extension.id} should have lost the data`
+ );
+ }
+
+ server.installCollection("storage-sync-crypto");
+ server.etag = 1000;
+
+ let newKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ extensionId2,
+ ]);
+ ok(
+ newKeys.hasKeysFor([extensionId]),
+ `key isn't present for ${extensionId}`
+ );
+ ok(
+ newKeys.hasKeysFor([extensionId2]),
+ `key isn't present for ${extensionId2}`
+ );
+
+ let posts = server.getPosts();
+ equal(posts.length, 1);
+ assertPostedNewRecord(posts[0]);
+
+ await assertSetAndGetData(extension, { "my-key": 1 });
+ await assertSetAndGetData(extension2, { "my-key": 2 });
+
+ // Call cleanup for the first extension, to double check it has
+ // been wiped out even without an active extension context.
+ cleanUpForContext(extension, context);
+
+ // clear everything.
+ await extensionStorageSync.clearAll();
+
+ // Assert that the data is gone for both the extensions.
+ await assertDataCleared(extension, ["my-key"]);
+ await assertDataCleared(extension2, ["my-key"]);
+
+ // should have been no posts caused by the clear.
+ posts = server.getPosts();
+ equal(posts.length, 1);
+ }
+ );
+ });
+
+ await testExtension.unload();
+});
+
+add_task(async function ensureCanSync_posts_new_keys() {
+ const extensionId = uuid();
+ await withContextAndServer(async function (context, server) {
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ server.installCollection("storage-sync-crypto");
+ server.etag = 1000;
+
+ let newKeys = await extensionStorageSync.ensureCanSync([extensionId]);
+ ok(
+ newKeys.hasKeysFor([extensionId]),
+ `key isn't present for ${extensionId}`
+ );
+
+ let posts = server.getPosts();
+ equal(posts.length, 1);
+ const post = posts[0];
+ assertPostedNewRecord(post);
+ const body = await assertPostedEncryptedKeys(fxaService, post);
+ const oldSalt = body.salts[extensionId];
+ ok(
+ body.keys.collections[extensionId],
+ `keys object should have a key for ${extensionId}`
+ );
+ ok(oldSalt, `salts object should have a salt for ${extensionId}`);
+
+ // Try adding another key to make sure that the first post was
+ // OK, even on a new profile.
+ await extensionStorageSync.cryptoCollection._clear();
+ server.clearPosts();
+ // Restore the first posted keyring, but add a last_modified date
+ const firstPostedKeyring = Object.assign({}, post.body.data, {
+ last_modified: server.etag,
+ });
+ server.addRecord({
+ data: firstPostedKeyring,
+ collectionId: "storage-sync-crypto",
+ predicate: appearsAt(250),
+ });
+ const extensionId2 = uuid();
+ newKeys = await extensionStorageSync.ensureCanSync([extensionId2]);
+ ok(
+ newKeys.hasKeysFor([extensionId]),
+ `didn't forget key for ${extensionId}`
+ );
+ ok(
+ newKeys.hasKeysFor([extensionId2]),
+ `new key generated for ${extensionId2}`
+ );
+
+ posts = server.getPosts();
+ equal(posts.length, 1);
+ const newPost = posts[posts.length - 1];
+ const newBody = await assertPostedEncryptedKeys(fxaService, newPost);
+ ok(
+ newBody.keys.collections[extensionId],
+ `keys object should have a key for ${extensionId}`
+ );
+ ok(
+ newBody.keys.collections[extensionId2],
+ `keys object should have a key for ${extensionId2}`
+ );
+ ok(
+ newBody.salts[extensionId],
+ `salts object should have a key for ${extensionId}`
+ );
+ ok(
+ newBody.salts[extensionId2],
+ `salts object should have a key for ${extensionId2}`
+ );
+ equal(
+ oldSalt,
+ newBody.salts[extensionId],
+ `old salt should be preserved in post`
+ );
+ }
+ );
+ });
+});
+
+add_task(async function ensureCanSync_pulls_key() {
+ // ensureCanSync is implemented by adding a key to our local record
+ // and doing a sync. This means that if the same key exists
+ // remotely, we get a "conflict". Ensure that we handle this
+ // correctly -- we keep the server key (since presumably it's
+ // already been used to encrypt records) and we don't wipe out other
+ // collections' keys.
+ const extensionId = uuid();
+ const extensionId2 = uuid();
+ const extensionOnlyKey = uuid();
+ const extensionOnlySalt = uuid();
+ const DEFAULT_KEY = new BulkKeyBundle("[default]");
+ await DEFAULT_KEY.generateRandom();
+ const RANDOM_KEY = new BulkKeyBundle(extensionId);
+ await RANDOM_KEY.generateRandom();
+ await withContextAndServer(async function (context, server) {
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ // FIXME: generating a random salt probably shouldn't require a CryptoCollection?
+ const cryptoCollection = new CryptoCollection(fxaService);
+ const RANDOM_SALT = cryptoCollection.getNewSalt();
+ await extensionStorageSync.cryptoCollection._clear();
+ const keysData = {
+ default: DEFAULT_KEY.keyPairB64,
+ collections: {
+ [extensionId]: RANDOM_KEY.keyPairB64,
+ },
+ };
+ const saltData = {
+ [extensionId]: RANDOM_SALT,
+ };
+ await server.installKeyRing(fxaService, keysData, saltData, 950, {
+ predicate: appearsAt(900),
+ });
+
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ assertKeyRingKey(collectionKeys, extensionId, RANDOM_KEY);
+
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 0,
+ "ensureCanSync shouldn't push when the server keyring has the right key"
+ );
+
+ // Another client generates a key for extensionId2
+ const newKey = new BulkKeyBundle(extensionId2);
+ await newKey.generateRandom();
+ keysData.collections[extensionId2] = newKey.keyPairB64;
+ saltData[extensionId2] = cryptoCollection.getNewSalt();
+ await server.installKeyRing(fxaService, keysData, saltData, 1050, {
+ predicate: appearsAt(1000),
+ });
+
+ let newCollectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ extensionId2,
+ ]);
+ assertKeyRingKey(newCollectionKeys, extensionId2, newKey);
+ assertKeyRingKey(
+ newCollectionKeys,
+ extensionId,
+ RANDOM_KEY,
+ `ensureCanSync shouldn't lose the old key for ${extensionId}`
+ );
+
+ posts = server.getPosts();
+ equal(
+ posts.length,
+ 0,
+ "ensureCanSync shouldn't push when updating keys"
+ );
+
+ // Another client generates a key, but not a salt, for extensionOnlyKey
+ const onlyKey = new BulkKeyBundle(extensionOnlyKey);
+ await onlyKey.generateRandom();
+ keysData.collections[extensionOnlyKey] = onlyKey.keyPairB64;
+ await server.installKeyRing(fxaService, keysData, saltData, 1150, {
+ predicate: appearsAt(1100),
+ });
+
+ let withNewKey = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ extensionOnlyKey,
+ ]);
+ dump(`got ${JSON.stringify(withNewKey.asWBO().cleartext)}\n`);
+ assertKeyRingKey(withNewKey, extensionOnlyKey, onlyKey);
+ assertKeyRingKey(
+ withNewKey,
+ extensionId,
+ RANDOM_KEY,
+ `ensureCanSync shouldn't lose the old key for ${extensionId}`
+ );
+
+ posts = server.getPosts();
+ equal(
+ posts.length,
+ 1,
+ "ensureCanSync should push when generating a new salt"
+ );
+ const withNewKeyRecord = await assertPostedEncryptedKeys(
+ fxaService,
+ posts[0]
+ );
+ // We don't a priori know what the new salt is
+ dump(`${JSON.stringify(withNewKeyRecord)}\n`);
+ ok(
+ withNewKeyRecord.salts[extensionOnlyKey],
+ `ensureCanSync should generate a salt for an extension that only had a key`
+ );
+
+ // Another client generates a key, but not a salt, for extensionOnlyKey
+ const newSalt = cryptoCollection.getNewSalt();
+ saltData[extensionOnlySalt] = newSalt;
+ await server.installKeyRing(fxaService, keysData, saltData, 1250, {
+ predicate: appearsAt(1200),
+ });
+
+ let withOnlySaltKey = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ extensionOnlySalt,
+ ]);
+ assertKeyRingKey(
+ withOnlySaltKey,
+ extensionId,
+ RANDOM_KEY,
+ `ensureCanSync shouldn't lose the old key for ${extensionId}`
+ );
+ // We don't a priori know what the new key is
+ ok(
+ withOnlySaltKey.hasKeysFor([extensionOnlySalt]),
+ `ensureCanSync generated a key for an extension that only had a salt`
+ );
+
+ posts = server.getPosts();
+ equal(
+ posts.length,
+ 2,
+ "ensureCanSync should push when generating a new key"
+ );
+ const withNewSaltRecord = await assertPostedEncryptedKeys(
+ fxaService,
+ posts[1]
+ );
+ equal(
+ withNewSaltRecord.salts[extensionOnlySalt],
+ newSalt,
+ "ensureCanSync should keep the existing salt when generating only a key"
+ );
+ }
+ );
+ });
+});
+
+add_task(async function ensureCanSync_handles_conflicts() {
+ // Syncing is done through a pull followed by a push of any merged
+ // changes. Accordingly, the only way to have a "true" conflict --
+ // i.e. with the server rejecting a change -- is if
+ // someone pushes changes between our pull and our push. Ensure that
+ // if this happens, we still behave sensibly (keep the remote key).
+ const extensionId = uuid();
+ const DEFAULT_KEY = new BulkKeyBundle("[default]");
+ await DEFAULT_KEY.generateRandom();
+ const RANDOM_KEY = new BulkKeyBundle(extensionId);
+ await RANDOM_KEY.generateRandom();
+ await withContextAndServer(async function (context, server) {
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ // FIXME: generating salts probably shouldn't rely on a CryptoCollection
+ const cryptoCollection = new CryptoCollection(fxaService);
+ const RANDOM_SALT = cryptoCollection.getNewSalt();
+ const keysData = {
+ default: DEFAULT_KEY.keyPairB64,
+ collections: {
+ [extensionId]: RANDOM_KEY.keyPairB64,
+ },
+ };
+ const saltData = {
+ [extensionId]: RANDOM_SALT,
+ };
+ await server.installKeyRing(fxaService, keysData, saltData, 765, {
+ conflict: true,
+ });
+
+ await extensionStorageSync.cryptoCollection._clear();
+
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ assertKeyRingKey(
+ collectionKeys,
+ extensionId,
+ RANDOM_KEY,
+ `syncing keyring should keep the server key for ${extensionId}`
+ );
+
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 1,
+ "syncing keyring should have tried to post a keyring"
+ );
+ const failedPost = posts[0];
+ assertPostedNewRecord(failedPost);
+ let body = await assertPostedEncryptedKeys(fxaService, failedPost);
+ // This key will be the one the client generated locally, so
+ // we don't know what its value will be
+ ok(
+ body.keys.collections[extensionId],
+ `decrypted failed post should have a key for ${extensionId}`
+ );
+ notEqual(
+ body.keys.collections[extensionId],
+ RANDOM_KEY.keyPairB64,
+ `decrypted failed post should have a randomly-generated key for ${extensionId}`
+ );
+ }
+ );
+ });
+});
+
+add_task(async function ensureCanSync_handles_deleted_conflicts() {
+ // A keyring can be deleted, and this changes the format of the 412
+ // Conflict response from the Kinto server. Make sure we handle it correctly.
+ const extensionId = uuid();
+ const extensionId2 = uuid();
+ await withContextAndServer(async function (context, server) {
+ server.installCollection("storage-sync-crypto");
+ server.installDeleteBucket();
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ server.etag = 700;
+ await extensionStorageSync.cryptoCollection._clear();
+
+ // Generate keys that we can check for later.
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ const extensionKey = collectionKeys.keyForCollection(extensionId);
+ server.clearPosts();
+
+ // This is the response that the Kinto server return when the
+ // keyring has been deleted.
+ server.addRecord({
+ collectionId: "storage-sync-crypto",
+ conflict: true,
+ transient: true,
+ data: null,
+ etag: 765,
+ });
+
+ // Try to add a new extension to trigger a sync of the keyring.
+ let collectionKeys2 = await extensionStorageSync.ensureCanSync([
+ extensionId2,
+ ]);
+
+ assertKeyRingKey(
+ collectionKeys2,
+ extensionId,
+ extensionKey,
+ `syncing keyring should keep our local key for ${extensionId}`
+ );
+
+ deepEqual(
+ server.getDeletedBuckets(),
+ ["default"],
+ "Kinto server should have been wiped when keyring was thrown away"
+ );
+
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 2,
+ "syncing keyring should have tried to post a keyring twice"
+ );
+ // The first post got a conflict.
+ const failedPost = posts[0];
+ assertPostedUpdatedRecord(failedPost, 700);
+ let body = await assertPostedEncryptedKeys(fxaService, failedPost);
+
+ deepEqual(
+ body.keys.collections[extensionId],
+ extensionKey.keyPairB64,
+ `decrypted failed post should have the key for ${extensionId}`
+ );
+
+ // The second post was after the wipe, and succeeded.
+ const afterWipePost = posts[1];
+ assertPostedNewRecord(afterWipePost);
+ let afterWipeBody = await assertPostedEncryptedKeys(
+ fxaService,
+ afterWipePost
+ );
+
+ deepEqual(
+ afterWipeBody.keys.collections[extensionId],
+ extensionKey.keyPairB64,
+ `decrypted new post should have preserved the key for ${extensionId}`
+ );
+ }
+ );
+ });
+});
+
+add_task(async function ensureCanSync_handles_flushes() {
+ // See Bug 1359879 and Bug 1350088. One of the ways that 1359879 presents is
+ // as 1350088. This seems to be the symptom that results when the user had
+ // two devices, one of which was not syncing at the time the keyring was
+ // lost. Ensure we can recover for these users as well.
+ const extensionId = uuid();
+ const extensionId2 = uuid();
+ await withContextAndServer(async function (context, server) {
+ server.installCollection("storage-sync-crypto");
+ server.installDeleteBucket();
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ server.etag = 700;
+ // Generate keys that we can check for later.
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ const extensionKey = collectionKeys.keyForCollection(extensionId);
+ server.clearPosts();
+
+ // last_modified is new, but there is no data.
+ server.etag = 800;
+
+ // Try to add a new extension to trigger a sync of the keyring.
+ let collectionKeys2 = await extensionStorageSync.ensureCanSync([
+ extensionId2,
+ ]);
+
+ assertKeyRingKey(
+ collectionKeys2,
+ extensionId,
+ extensionKey,
+ `syncing keyring should keep our local key for ${extensionId}`
+ );
+
+ deepEqual(
+ server.getDeletedBuckets(),
+ ["default"],
+ "Kinto server should have been wiped when keyring was thrown away"
+ );
+
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 1,
+ "syncing keyring should have tried to post a keyring once"
+ );
+
+ const post = posts[0];
+ assertPostedNewRecord(post);
+ let postBody = await assertPostedEncryptedKeys(fxaService, post);
+
+ deepEqual(
+ postBody.keys.collections[extensionId],
+ extensionKey.keyPairB64,
+ `decrypted new post should have preserved the key for ${extensionId}`
+ );
+ }
+ );
+ });
+});
+
+add_task(async function checkSyncKeyRing_reuploads_keys() {
+ // Verify that when keys are present, they are reuploaded with the
+ // new kbHash when we call touchKeys().
+ const extensionId = uuid();
+ let extensionKey, extensionSalt;
+ await withContextAndServer(async function (context, server) {
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ server.installCollection("storage-sync-crypto");
+ server.etag = 765;
+
+ await extensionStorageSync.cryptoCollection._clear();
+
+ // Do an `ensureCanSync` to generate some keys.
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ ok(
+ collectionKeys.hasKeysFor([extensionId]),
+ `ensureCanSync should return a keyring that has a key for ${extensionId}`
+ );
+ extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64;
+ equal(
+ server.getPosts().length,
+ 1,
+ "generating a key that doesn't exist on the server should post it"
+ );
+ const body = await assertPostedEncryptedKeys(
+ fxaService,
+ server.getPosts()[0]
+ );
+ extensionSalt = body.salts[extensionId];
+ }
+ );
+
+ // The user changes their password. This is their new kbHash, with
+ // the last character changed.
+ const newUser = Object.assign({}, loggedInUser, {
+ scopedKeys: {
+ "sync:addon_storage": {
+ kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE",
+ k: "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA",
+ kty: "oct",
+ },
+ },
+ });
+ let postedKeys;
+ await withSignedInUser(
+ newUser,
+ async function (extensionStorageSync, fxaService) {
+ await extensionStorageSync.checkSyncKeyRing();
+
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 2,
+ "when kBHash changes, checkSyncKeyRing should post the keyring reencrypted with the new kBHash"
+ );
+ postedKeys = posts[1];
+ assertPostedUpdatedRecord(postedKeys, 765);
+
+ let body = await assertPostedEncryptedKeys(fxaService, postedKeys);
+ deepEqual(
+ body.keys.collections[extensionId],
+ extensionKey,
+ `the posted keyring should have the same key for ${extensionId} as the old one`
+ );
+ deepEqual(
+ body.salts[extensionId],
+ extensionSalt,
+ `the posted keyring should have the same salt for ${extensionId} as the old one`
+ );
+ }
+ );
+
+ // Verify that with the old kBHash, we can't decrypt the record.
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ let error;
+ try {
+ await new KeyRingEncryptionRemoteTransformer(fxaService).decode(
+ postedKeys.body.data
+ );
+ } catch (e) {
+ error = e;
+ }
+ ok(error, "decrypting the keyring with the old kBHash should fail");
+ ok(
+ Utils.isHMACMismatch(error) ||
+ KeyRingEncryptionRemoteTransformer.isOutdatedKB(error),
+ "decrypting the keyring with the old kBHash should throw an HMAC mismatch"
+ );
+ }
+ );
+ });
+});
+
+add_task(async function checkSyncKeyRing_overwrites_on_conflict() {
+ // If there is already a record on the server that was encrypted
+ // with a different kbHash, we wipe the server, clear sync state, and
+ // overwrite it with our keys.
+ const extensionId = uuid();
+ let extensionKey;
+ await withSyncContext(async function (context) {
+ await withServer(async function (server) {
+ // The old device has this kbHash, which is very similar to the
+ // current kbHash but with the last character changed.
+ const oldUser = Object.assign({}, loggedInUser, {
+ scopedKeys: {
+ "sync:addon_storage": {
+ kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE",
+ k: "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA",
+ kty: "oct",
+ },
+ },
+ });
+ server.installDeleteBucket();
+ await withSignedInUser(
+ oldUser,
+ async function (extensionStorageSync, fxaService) {
+ await server.installKeyRing(fxaService, {}, {}, 765);
+ }
+ );
+
+ // Now we have this new user with a different kbHash.
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ await extensionStorageSync.cryptoCollection._clear();
+
+ // Do an `ensureCanSync` to generate some keys.
+ // This will try to sync, notice that the record is
+ // undecryptable, and clear the server.
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ ok(
+ collectionKeys.hasKeysFor([extensionId]),
+ `ensureCanSync should always return a keyring with a key for ${extensionId}`
+ );
+ extensionKey =
+ collectionKeys.keyForCollection(extensionId).keyPairB64;
+
+ deepEqual(
+ server.getDeletedBuckets(),
+ ["default"],
+ "Kinto server should have been wiped when keyring was thrown away"
+ );
+
+ let posts = server.getPosts();
+ equal(posts.length, 1, "new keyring should have been uploaded");
+ const postedKeys = posts[0];
+ // The POST was to an empty server, so etag shouldn't be respected
+ equal(
+ postedKeys.headers.Authorization,
+ "Bearer some-access-token",
+ "keyring upload should be authorized"
+ );
+ equal(
+ postedKeys.headers["If-None-Match"],
+ "*",
+ "keyring upload should be to empty Kinto server"
+ );
+ equal(
+ postedKeys.path,
+ collectionRecordsPath("storage-sync-crypto") + "/keys",
+ "keyring upload should be to keyring path"
+ );
+
+ let body = await new KeyRingEncryptionRemoteTransformer(
+ fxaService
+ ).decode(postedKeys.body.data);
+ ok(body.uuid, "new keyring should have a UUID");
+ equal(typeof body.uuid, "string", "keyring UUIDs should be strings");
+ notEqual(
+ body.uuid,
+ "abcd",
+ "new keyring should not have the same UUID as previous keyring"
+ );
+ ok(body.keys, "new keyring should have a keys attribute");
+ ok(body.keys.default, "new keyring should have a default key");
+ // We should keep the extension key that was in our uploaded version.
+ deepEqual(
+ extensionKey,
+ body.keys.collections[extensionId],
+ "ensureCanSync should have returned keyring with the same key that was uploaded"
+ );
+
+ // This should be a no-op; the keys were uploaded as part of ensurekeysfor
+ await extensionStorageSync.checkSyncKeyRing();
+ equal(
+ server.getPosts().length,
+ 1,
+ "checkSyncKeyRing should not need to post keys after they were reuploaded"
+ );
+ }
+ );
+ });
+ });
+});
+
+add_task(async function checkSyncKeyRing_flushes_on_uuid_change() {
+ // If we can decrypt the record, but the UUID has changed, that
+ // means another client has wiped the server and reuploaded a
+ // keyring, so reset sync state and reupload everything.
+ const extensionId = uuid();
+ const extension = { id: extensionId };
+ await withSyncContext(async function (context) {
+ await withServer(async function (server) {
+ server.installCollection("storage-sync-crypto");
+ server.installDeleteBucket();
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ const transformer = new KeyRingEncryptionRemoteTransformer(
+ fxaService
+ );
+ await extensionStorageSync.cryptoCollection._clear();
+
+ // Do an `ensureCanSync` to get access to keys and salt.
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ server.installCollection(collectionId);
+
+ ok(
+ collectionKeys.hasKeysFor([extensionId]),
+ `ensureCanSync should always return a keyring that has a key for ${extensionId}`
+ );
+ const extensionKey =
+ collectionKeys.keyForCollection(extensionId).keyPairB64;
+
+ // Set something to make sure that it gets re-uploaded when
+ // uuid changes.
+ await extensionStorageSync.set(extension, { "my-key": 5 }, context);
+ await extensionStorageSync.syncAll();
+
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 2,
+ "should have posted a new keyring and an extension datum"
+ );
+ const postedKeys = posts[0];
+ equal(
+ postedKeys.path,
+ collectionRecordsPath("storage-sync-crypto") + "/keys",
+ "should have posted keyring to /keys"
+ );
+
+ let body = await transformer.decode(postedKeys.body.data);
+ ok(body.uuid, "keyring should have a UUID");
+ ok(body.keys, "keyring should have a keys attribute");
+ ok(body.keys.default, "keyring should have a default key");
+ ok(
+ body.salts[extensionId],
+ `keyring should have a salt for ${extensionId}`
+ );
+ const extensionSalt = body.salts[extensionId];
+ deepEqual(
+ extensionKey,
+ body.keys.collections[extensionId],
+ "new keyring should have the same key that we uploaded"
+ );
+
+ // Another client comes along and replaces the UUID.
+ // In real life, this would mean changing the keys too, but
+ // this test verifies that just changing the UUID is enough.
+ const newKeyRingData = Object.assign({}, body, {
+ uuid: "abcd",
+ // Technically, last_modified should be served outside the
+ // object, but the transformer will pass it through in
+ // either direction, so this is OK.
+ last_modified: 765,
+ });
+ server.etag = 1000;
+ await server.encryptAndAddRecord(transformer, {
+ collectionId: "storage-sync-crypto",
+ data: newKeyRingData,
+ predicate: appearsAt(800),
+ });
+
+ // Fake adding another extension just so that the keyring will
+ // really get synced.
+ const newExtension = uuid();
+ const newKeyRing = await extensionStorageSync.ensureCanSync([
+ newExtension,
+ ]);
+
+ // This should have detected the UUID change and flushed everything.
+ // The keyring should, however, be the same, since we just
+ // changed the UUID of the previously POSTed one.
+ deepEqual(
+ newKeyRing.keyForCollection(extensionId).keyPairB64,
+ extensionKey,
+ "ensureCanSync should have pulled down a new keyring with the same keys"
+ );
+
+ // Syncing should reupload the data for the extension.
+ await extensionStorageSync.syncAll();
+ posts = server.getPosts();
+ equal(
+ posts.length,
+ 4,
+ "should have posted keyring for new extension and reuploaded extension data"
+ );
+
+ const finalKeyRingPost = posts[2];
+ const reuploadedPost = posts[3];
+
+ equal(
+ finalKeyRingPost.path,
+ collectionRecordsPath("storage-sync-crypto") + "/keys",
+ "keyring for new extension should have been posted to /keys"
+ );
+ let finalKeyRing = await transformer.decode(
+ finalKeyRingPost.body.data
+ );
+ equal(
+ finalKeyRing.uuid,
+ "abcd",
+ "newly uploaded keyring should preserve UUID from replacement keyring"
+ );
+ deepEqual(
+ finalKeyRing.salts[extensionId],
+ extensionSalt,
+ "newly uploaded keyring should preserve salts from existing salts"
+ );
+
+ // Confirm that the data got reuploaded
+ let reuploadedData = await assertExtensionRecord(
+ fxaService,
+ reuploadedPost,
+ extension,
+ "my-key"
+ );
+ equal(
+ reuploadedData.data,
+ 5,
+ "extension data should have a data attribute corresponding to the extension data value"
+ );
+ }
+ );
+ });
+ });
+});
+
+add_task(async function test_storage_sync_pulls_changes() {
+ const extensionId = defaultExtensionId;
+ const extension = defaultExtension;
+ await withContextAndServer(async function (context, server) {
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ server.installCollection("storage-sync-crypto");
+
+ let calls = [];
+ await extensionStorageSync.addOnChangedListener(
+ extension,
+ function () {
+ calls.push(arguments);
+ },
+ context
+ );
+
+ await extensionStorageSync.ensureCanSync([extensionId]);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ let transformer = new CollectionKeyEncryptionRemoteTransformer(
+ cryptoCollection,
+ await cryptoCollection.getKeyRing(),
+ extensionId
+ );
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 6,
+ },
+ predicate: appearsAt(850),
+ });
+ server.etag = 900;
+
+ await extensionStorageSync.syncAll();
+ const remoteValue = (
+ await extensionStorageSync.get(extension, "remote-key", context)
+ )["remote-key"];
+ equal(
+ remoteValue,
+ 6,
+ "ExtensionStorageSync.get() returns value retrieved from sync"
+ );
+
+ equal(calls.length, 1, "syncing calls on-changed listener");
+ deepEqual(calls[0][0], { "remote-key": { newValue: 6 } });
+ calls = [];
+
+ // Syncing again doesn't do anything
+ await extensionStorageSync.syncAll();
+
+ equal(
+ calls.length,
+ 0,
+ "syncing again shouldn't call on-changed listener"
+ );
+
+ // Updating the server causes us to pull down the new value
+ server.etag = 1000;
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 7,
+ },
+ predicate: appearsAt(950),
+ });
+
+ await extensionStorageSync.syncAll();
+ const remoteValue2 = (
+ await extensionStorageSync.get(extension, "remote-key", context)
+ )["remote-key"];
+ equal(
+ remoteValue2,
+ 7,
+ "ExtensionStorageSync.get() returns value updated from sync"
+ );
+
+ equal(calls.length, 1, "syncing calls on-changed listener on update");
+ deepEqual(calls[0][0], { "remote-key": { oldValue: 6, newValue: 7 } });
+ }
+ );
+ });
+});
+
+// Tests that an enabled extension which have been synced before it is going
+// to be synced on ExtensionStorageSync.syncAll even if there is no active
+// context that is currently using the API.
+add_task(async function test_storage_sync_on_no_active_context() {
+ const extensionId = "sync@mochi.test";
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: { gecko: { id: extensionId } },
+ },
+ files: {
+ "ext-page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <script src="ext-page.js"></script>
+ </head>
+ </html>
+ `,
+ "ext-page.js": function () {
+ const { browser } = this;
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "get-sync-data") {
+ browser.test.sendMessage(
+ "get-sync-data:done",
+ await browser.storage.sync.get(["remote-key"])
+ );
+ }
+ });
+ },
+ },
+ });
+
+ await extension.startup();
+
+ await withServer(async server => {
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ server.installCollection("storage-sync-crypto");
+
+ await extensionStorageSync.ensureCanSync([extensionId]);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ let transformer = new CollectionKeyEncryptionRemoteTransformer(
+ cryptoCollection,
+ await cryptoCollection.getKeyRing(),
+ extensionId
+ );
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 6,
+ },
+ predicate: appearsAt(850),
+ });
+
+ server.etag = 1000;
+ await extensionStorageSync.syncAll();
+ }
+ );
+ });
+
+ const extPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/ext-page.html`,
+ { extension }
+ );
+
+ await extension.sendMessage("get-sync-data");
+ const res = await extension.awaitMessage("get-sync-data:done");
+ Assert.deepEqual(res, { "remote-key": 6 }, "Got the expected sync data");
+
+ await extPage.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_storage_sync_pushes_changes() {
+ // FIXME: This test relies on the fact that previous tests pushed
+ // keys and salts for the default extension ID
+ const extension = defaultExtension;
+ const extensionId = defaultExtensionId;
+ await withContextAndServer(async function (context, server) {
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ server.installCollection(collectionId);
+ server.installCollection("storage-sync-crypto");
+ server.etag = 1000;
+
+ await extensionStorageSync.set(extension, { "my-key": 5 }, context);
+
+ // install this AFTER we set the key to 5...
+ let calls = [];
+ extensionStorageSync.addOnChangedListener(
+ extension,
+ function () {
+ calls.push(arguments);
+ },
+ context
+ );
+
+ await extensionStorageSync.syncAll();
+ const localValue = (
+ await extensionStorageSync.get(extension, "my-key", context)
+ )["my-key"];
+ equal(
+ localValue,
+ 5,
+ "pushing an ExtensionStorageSync value shouldn't change local value"
+ );
+ const hashedId =
+ "id-" +
+ (await cryptoCollection.hashWithExtensionSalt(
+ "key-my_2D_key",
+ extensionId
+ ));
+
+ let posts = server.getPosts();
+ // FIXME: Keys were pushed in a previous test
+ equal(
+ posts.length,
+ 1,
+ "pushing a value should cause a post to the server"
+ );
+ const post = posts[0];
+ assertPostedNewRecord(post);
+ equal(
+ post.path,
+ `${collectionRecordsPath(collectionId)}/${hashedId}`,
+ "pushing a value should have a path corresponding to its id"
+ );
+
+ const encrypted = post.body.data;
+ ok(
+ encrypted.ciphertext,
+ "pushing a value should post an encrypted record"
+ );
+ ok(
+ !encrypted.data,
+ "pushing a value should not have any plaintext data"
+ );
+ equal(
+ encrypted.id,
+ hashedId,
+ "pushing a value should use a kinto-friendly record ID"
+ );
+
+ const record = await assertExtensionRecord(
+ fxaService,
+ post,
+ extension,
+ "my-key"
+ );
+ equal(
+ record.data,
+ 5,
+ "when decrypted, a pushed value should have a data field corresponding to its storage.sync value"
+ );
+ equal(
+ record.id,
+ "key-my_2D_key",
+ "when decrypted, a pushed value should have an id field corresponding to its record ID"
+ );
+
+ equal(
+ calls.length,
+ 0,
+ "pushing a value shouldn't call the on-changed listener"
+ );
+
+ await extensionStorageSync.set(extension, { "my-key": 6 }, context);
+ await extensionStorageSync.syncAll();
+
+ // Doesn't push keys because keys were pushed by a previous test.
+ posts = server.getPosts();
+ equal(posts.length, 2, "updating a value should trigger another push");
+ const updatePost = posts[1];
+ assertPostedUpdatedRecord(updatePost, 1000);
+ equal(
+ updatePost.path,
+ `${collectionRecordsPath(collectionId)}/${hashedId}`,
+ "pushing an updated value should go to the same path"
+ );
+
+ const updateEncrypted = updatePost.body.data;
+ ok(
+ updateEncrypted.ciphertext,
+ "pushing an updated value should still be encrypted"
+ );
+ ok(
+ !updateEncrypted.data,
+ "pushing an updated value should not have any plaintext visible"
+ );
+ equal(
+ updateEncrypted.id,
+ hashedId,
+ "pushing an updated value should maintain the same ID"
+ );
+ }
+ );
+ });
+});
+
+add_task(async function test_storage_sync_retries_failed_auth() {
+ const extensionId = uuid();
+ const extension = { id: extensionId };
+ await withContextAndServer(async function (context, server) {
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ server.installCollection("storage-sync-crypto");
+
+ await extensionStorageSync.ensureCanSync([extensionId]);
+ await extensionStorageSync.set(extension, { "my-key": 5 }, context);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ let transformer = new CollectionKeyEncryptionRemoteTransformer(
+ cryptoCollection,
+ await cryptoCollection.getKeyRing(),
+ extensionId
+ );
+ // Put a remote record just to verify that eventually we succeeded
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 6,
+ },
+ predicate: appearsAt(850),
+ });
+ server.etag = 900;
+
+ // This is a typical response from a production stack if your
+ // bearer token is bad.
+ server.rejectNextAuthWith(
+ '{"code": 401, "errno": 104, "error": "Unauthorized", "message": "Please authenticate yourself to use this endpoint"}'
+ );
+ await extensionStorageSync.syncAll();
+
+ equal(server.failedAuths.length, 1, "an auth was failed");
+
+ const remoteValue = (
+ await extensionStorageSync.get(extension, "remote-key", context)
+ )["remote-key"];
+ equal(
+ remoteValue,
+ 6,
+ "ExtensionStorageSync.get() returns value retrieved from sync"
+ );
+
+ // Try again with an emptier JSON body to make sure this still
+ // works with a less-cooperative server.
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 7,
+ },
+ predicate: appearsAt(950),
+ });
+ server.etag = 1000;
+ // Need to write a JSON response.
+ // kinto.js 9.0.2 doesn't throw unless there's json.
+ // See https://github.com/Kinto/kinto-http.js/issues/192.
+ server.rejectNextAuthWith("{}");
+
+ await extensionStorageSync.syncAll();
+
+ equal(server.failedAuths.length, 2, "an auth was failed");
+
+ const newRemoteValue = (
+ await extensionStorageSync.get(extension, "remote-key", context)
+ )["remote-key"];
+ equal(
+ newRemoteValue,
+ 7,
+ "ExtensionStorageSync.get() returns value retrieved from sync"
+ );
+ }
+ );
+ });
+});
+
+add_task(async function test_storage_sync_pulls_conflicts() {
+ const extensionId = uuid();
+ const extension = { id: extensionId };
+ await withContextAndServer(async function (context, server) {
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ server.installCollection("storage-sync-crypto");
+
+ await extensionStorageSync.ensureCanSync([extensionId]);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ let transformer = new CollectionKeyEncryptionRemoteTransformer(
+ cryptoCollection,
+ await cryptoCollection.getKeyRing(),
+ extensionId
+ );
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 6,
+ },
+ predicate: appearsAt(850),
+ });
+ server.etag = 900;
+
+ await extensionStorageSync.set(extension, { "remote-key": 8 }, context);
+
+ let calls = [];
+ await extensionStorageSync.addOnChangedListener(
+ extension,
+ function () {
+ calls.push(arguments);
+ },
+ context
+ );
+
+ await extensionStorageSync.syncAll();
+ const remoteValue = (
+ await extensionStorageSync.get(extension, "remote-key", context)
+ )["remote-key"];
+ equal(remoteValue, 8, "locally set value overrides remote value");
+
+ equal(calls.length, 1, "conflicts manifest in on-changed listener");
+ deepEqual(calls[0][0], { "remote-key": { newValue: 8 } });
+ calls = [];
+
+ // Syncing again doesn't do anything
+ await extensionStorageSync.syncAll();
+
+ equal(
+ calls.length,
+ 0,
+ "syncing again shouldn't call on-changed listener"
+ );
+
+ // Updating the server causes us to pull down the new value
+ server.etag = 1000;
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 7,
+ },
+ predicate: appearsAt(950),
+ });
+
+ await extensionStorageSync.syncAll();
+ const remoteValue2 = (
+ await extensionStorageSync.get(extension, "remote-key", context)
+ )["remote-key"];
+ equal(
+ remoteValue2,
+ 7,
+ "conflicts do not prevent retrieval of new values"
+ );
+
+ equal(calls.length, 1, "syncing calls on-changed listener on update");
+ deepEqual(calls[0][0], { "remote-key": { oldValue: 8, newValue: 7 } });
+ }
+ );
+ });
+});
+
+add_task(async function test_storage_sync_pulls_deletes() {
+ const extension = defaultExtension;
+ await withContextAndServer(async function (context, server) {
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ defaultExtensionId
+ );
+ server.installCollection(collectionId);
+ server.installCollection("storage-sync-crypto");
+
+ await extensionStorageSync.set(extension, { "my-key": 5 }, context);
+ await extensionStorageSync.syncAll();
+ server.clearPosts();
+
+ let calls = [];
+ await extensionStorageSync.addOnChangedListener(
+ extension,
+ function () {
+ calls.push(arguments);
+ },
+ context
+ );
+
+ const transformer = new CollectionKeyEncryptionRemoteTransformer(
+ new CryptoCollection(fxaService),
+ await cryptoCollection.getKeyRing(),
+ extension.id
+ );
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-my_2D_key",
+ data: 6,
+ _status: "deleted",
+ },
+ });
+
+ await extensionStorageSync.syncAll();
+ const remoteValues = await extensionStorageSync.get(
+ extension,
+ "my-key",
+ context
+ );
+ ok(
+ !remoteValues["my-key"],
+ "ExtensionStorageSync.get() shows value was deleted by sync"
+ );
+
+ equal(
+ server.getPosts().length,
+ 0,
+ "pulling the delete shouldn't cause posts"
+ );
+
+ equal(calls.length, 1, "syncing calls on-changed listener");
+ deepEqual(calls[0][0], { "my-key": { oldValue: 5 } });
+ calls = [];
+
+ // Syncing again doesn't do anything
+ await extensionStorageSync.syncAll();
+
+ equal(
+ calls.length,
+ 0,
+ "syncing again shouldn't call on-changed listener"
+ );
+ }
+ );
+ });
+});
+
+add_task(async function test_storage_sync_pushes_deletes() {
+ const extensionId = uuid();
+ const extension = { id: extensionId };
+ await withContextAndServer(async function (context, server) {
+ await withSignedInUser(
+ loggedInUser,
+ async function (extensionStorageSync, fxaService) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ await cryptoCollection._clear();
+ await cryptoCollection._setSalt(
+ extensionId,
+ cryptoCollection.getNewSalt()
+ );
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+
+ server.installCollection(collectionId);
+ server.installCollection("storage-sync-crypto");
+ server.etag = 1000;
+
+ await extensionStorageSync.set(extension, { "my-key": 5 }, context);
+
+ let calls = [];
+ extensionStorageSync.addOnChangedListener(
+ extension,
+ function () {
+ calls.push(arguments);
+ },
+ context
+ );
+
+ await extensionStorageSync.syncAll();
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 2,
+ "pushing a non-deleted value should post keys and post the value to the server"
+ );
+
+ await extensionStorageSync.remove(extension, ["my-key"], context);
+ equal(
+ calls.length,
+ 1,
+ "deleting a value should call the on-changed listener"
+ );
+
+ await extensionStorageSync.syncAll();
+ equal(
+ calls.length,
+ 1,
+ "pushing a deleted value shouldn't call the on-changed listener"
+ );
+
+ // Doesn't push keys because keys were pushed by a previous test.
+ const hashedId =
+ "id-" +
+ (await cryptoCollection.hashWithExtensionSalt(
+ "key-my_2D_key",
+ extensionId
+ ));
+ posts = server.getPosts();
+ equal(posts.length, 3, "deleting a value should trigger another push");
+ const post = posts[2];
+ assertPostedUpdatedRecord(post, 1000);
+ equal(
+ post.path,
+ `${collectionRecordsPath(collectionId)}/${hashedId}`,
+ "pushing a deleted value should go to the same path"
+ );
+ ok(post.method, "PUT");
+ ok(
+ post.body.data.ciphertext,
+ "deleting a value should have an encrypted body"
+ );
+ const decoded = await new CollectionKeyEncryptionRemoteTransformer(
+ cryptoCollection,
+ await cryptoCollection.getKeyRing(),
+ extensionId
+ ).decode(post.body.data);
+ equal(decoded._status, "deleted");
+ // Ideally, we'd check that decoded.deleted is not true, because
+ // the encrypted record shouldn't have it, but the decoder will
+ // add it when it sees _status == deleted
+ }
+ );
+ });
+});
+
+// Some sync tests shared between implementations.
+add_task(test_config_flag_needed);
+
+add_task(test_sync_reloading_extensions_works);
+
+add_task(function test_storage_sync() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_background_page_storage("sync")
+ );
+});
+
+add_task(test_storage_sync_requires_real_id);
+
+add_task(function test_storage_sync_with_bytes_in_use() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_background_storage_area_with_bytes_in_use("sync", false)
+ );
+});
+
+add_task(function test_storage_onChanged_event_page() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_storage_change_event_page("sync")
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js
new file mode 100644
index 0000000000..8d99083fa8
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js
@@ -0,0 +1,122 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This is a kinto-specific test...
+Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true);
+
+const {
+ KintoStorageTestUtils: { EncryptionRemoteTransformer },
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs"
+);
+const { CryptoUtils } = ChromeUtils.importESModule(
+ "resource://services-crypto/utils.sys.mjs"
+);
+const { Utils } = ChromeUtils.importESModule(
+ "resource://services-sync/util.sys.mjs"
+);
+
+/**
+ * Like Assert.throws, but for generators.
+ *
+ * @param {string | object | Function} constraint
+ * What to use to check the exception.
+ * @param {Function} f
+ * The function to call.
+ */
+async function throwsGen(constraint, f) {
+ let threw = false;
+ let exception;
+ try {
+ await f();
+ } catch (e) {
+ threw = true;
+ exception = e;
+ }
+
+ ok(threw, "did not throw an exception");
+
+ const debuggingMessage = `got ${exception}, expected ${constraint}`;
+
+ if (typeof constraint === "function") {
+ ok(constraint(exception), debuggingMessage);
+ } else {
+ let message = exception;
+ if (typeof exception === "object") {
+ message = exception.message;
+ }
+ ok(constraint === message, debuggingMessage);
+ }
+}
+
+/**
+ * An EncryptionRemoteTransformer that uses a fixed key bundle,
+ * suitable for testing.
+ */
+class StaticKeyEncryptionRemoteTransformer extends EncryptionRemoteTransformer {
+ constructor(keyBundle) {
+ super();
+ this.keyBundle = keyBundle;
+ }
+
+ getKeys() {
+ return Promise.resolve(this.keyBundle);
+ }
+}
+const BORING_KB =
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+let transformer;
+add_task(async function setup() {
+ const STRETCHED_KEY = await CryptoUtils.hkdfLegacy(
+ BORING_KB,
+ undefined,
+ `testing storage.sync encryption`,
+ 2 * 32
+ );
+ const KEY_BUNDLE = {
+ hmacKey: STRETCHED_KEY.slice(0, 32),
+ encryptionKeyB64: btoa(STRETCHED_KEY.slice(32, 64)),
+ };
+ transformer = new StaticKeyEncryptionRemoteTransformer(KEY_BUNDLE);
+});
+
+add_task(async function test_encryption_transformer_roundtrip() {
+ const POSSIBLE_DATAS = [
+ "string",
+ 2, // number
+ [1, 2, 3], // array
+ { key: "value" }, // object
+ ];
+
+ for (let data of POSSIBLE_DATAS) {
+ const record = { data, id: "key-some_2D_key", key: "some-key" };
+
+ deepEqual(
+ record,
+ await transformer.decode(await transformer.encode(record))
+ );
+ }
+});
+
+add_task(async function test_refuses_to_decrypt_tampered() {
+ const encryptedRecord = await transformer.encode({
+ data: [1, 2, 3],
+ id: "key-some_2D_key",
+ key: "some-key",
+ });
+ const tamperedHMAC = Object.assign({}, encryptedRecord, {
+ hmac: "0000000000000000000000000000000000000000000000000000000000000001",
+ });
+ await throwsGen(Utils.isHMACMismatch, async function () {
+ await transformer.decode(tamperedHMAC);
+ });
+
+ const tamperedIV = Object.assign({}, encryptedRecord, {
+ IV: "aaaaaaaaaaaaaaaaaaaaaa==",
+ });
+ await throwsGen(Utils.isHMACMismatch, async function () {
+ await transformer.decode(tamperedIV);
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js
new file mode 100644
index 0000000000..cfa49c334b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js
@@ -0,0 +1,245 @@
+"use strict";
+
+const { ExtensionStorageIDB } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorageIDB.sys.mjs"
+);
+
+async function test_multiple_pages() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ function awaitMessage(expectedMsg, api = "test") {
+ return new Promise(resolve => {
+ browser[api].onMessage.addListener(function listener(msg) {
+ if (msg === expectedMsg) {
+ browser[api].onMessage.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+ }
+
+ let tabReady = awaitMessage("tab-ready", "runtime");
+
+ try {
+ let storage = browser.storage.local;
+
+ browser.test.sendMessage(
+ "load-page",
+ browser.runtime.getURL("tab.html")
+ );
+ await awaitMessage("page-loaded");
+ await tabReady;
+
+ let result = await storage.get("key");
+ browser.test.assertEq(undefined, result.key, "Key should be undefined");
+
+ await browser.runtime.sendMessage("tab-set-key");
+
+ result = await storage.get("key");
+ browser.test.assertEq(
+ JSON.stringify({ foo: { bar: "baz" } }),
+ JSON.stringify(result.key),
+ "Key should be set to the value from the tab"
+ );
+
+ browser.test.sendMessage("remove-page");
+ await awaitMessage("page-removed");
+
+ result = await storage.get("key");
+ browser.test.assertEq(
+ JSON.stringify({ foo: { bar: "baz" } }),
+ JSON.stringify(result.key),
+ "Key should still be set to the value from the tab"
+ );
+
+ browser.test.notifyPass("storage-multiple");
+ } catch (e) {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ browser.test.notifyFail("storage-multiple");
+ }
+ },
+
+ files: {
+ "tab.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="tab.js"></script>
+ </head>
+ </html>`,
+
+ "tab.js"() {
+ browser.test.log("tab");
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg == "tab-set-key") {
+ return browser.storage.local.set({ key: { foo: { bar: "baz" } } });
+ }
+ });
+
+ browser.runtime.sendMessage("tab-ready");
+ },
+ },
+
+ manifest: {
+ permissions: ["storage"],
+ },
+ });
+
+ let contentPage;
+ extension.onMessage("load-page", async url => {
+ contentPage = await ExtensionTestUtils.loadContentPage(url, { extension });
+ extension.sendMessage("page-loaded");
+ });
+ extension.onMessage("remove-page", async url => {
+ await contentPage.close();
+ extension.sendMessage("page-removed");
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("storage-multiple");
+ await extension.unload();
+}
+
+add_task(async function test_storage_local_file_backend_from_tab() {
+ return runWithPrefs(
+ [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]],
+ test_multiple_pages
+ );
+});
+
+add_task(async function test_storage_local_idb_backend_from_tab() {
+ return runWithPrefs(
+ [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]],
+ test_multiple_pages
+ );
+});
+
+async function test_storage_local_call_from_destroying_context() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let numberOfChanges = 0;
+ browser.storage.onChanged.addListener((changes, areaName) => {
+ if (areaName !== "local") {
+ browser.test.fail(
+ `Received unexpected storage changes for "${areaName}"`
+ );
+ }
+
+ numberOfChanges++;
+ });
+
+ browser.test.onMessage.addListener(async ({ msg, values }) => {
+ switch (msg) {
+ case "storage-set": {
+ await browser.storage.local.set(values);
+ browser.test.sendMessage("storage-set:done");
+ break;
+ }
+ case "storage-get": {
+ const res = await browser.storage.local.get();
+ browser.test.sendMessage("storage-get:done", res);
+ break;
+ }
+ case "storage-changes": {
+ browser.test.sendMessage("storage-changes-count", numberOfChanges);
+ break;
+ }
+ default:
+ browser.test.fail(`Received unexpected message: ${msg}`);
+ }
+ });
+
+ browser.test.sendMessage(
+ "ext-page-url",
+ browser.runtime.getURL("tab.html")
+ );
+ },
+ files: {
+ "tab.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="tab.js"></script>
+ </head>
+ </html>`,
+
+ "tab.js"() {
+ browser.test.log("Extension tab - calling storage.local API method");
+ // Call the storage.local API from a tab that is going to be quickly closed.
+ browser.storage.local.set({
+ "test-key-from-destroying-context": "testvalue2",
+ });
+ // Navigate away from the extension page, so that the storage.local API call will be unable
+ // to send the call to the caller context (because it has been destroyed in the meantime).
+ window.location = "about:blank";
+ },
+ },
+ manifest: {
+ permissions: ["storage"],
+ },
+ });
+
+ await extension.startup();
+ const url = await extension.awaitMessage("ext-page-url");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ extension,
+ });
+ let expectedBackgroundPageData = {
+ "test-key-from-background-page": "test-value",
+ };
+ let expectedTabData = { "test-key-from-destroying-context": "testvalue2" };
+
+ info(
+ "Call storage.local.set from the background page and wait it to be completed"
+ );
+ extension.sendMessage({
+ msg: "storage-set",
+ values: expectedBackgroundPageData,
+ });
+ await extension.awaitMessage("storage-set:done");
+
+ info(
+ "Call storage.local.get from the background page and wait it to be completed"
+ );
+ extension.sendMessage({ msg: "storage-get" });
+ let res = await extension.awaitMessage("storage-get:done");
+
+ Assert.deepEqual(
+ res,
+ {
+ ...expectedBackgroundPageData,
+ ...expectedTabData,
+ },
+ "Got the expected data set in the storage.local backend"
+ );
+
+ extension.sendMessage({ msg: "storage-changes" });
+ equal(
+ await extension.awaitMessage("storage-changes-count"),
+ 2,
+ "Got the expected number of storage.onChanged event received"
+ );
+
+ contentPage.close();
+
+ await extension.unload();
+}
+
+add_task(
+ async function test_storage_local_file_backend_destroyed_context_promise() {
+ return runWithPrefs(
+ [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]],
+ test_storage_local_call_from_destroying_context
+ );
+ }
+);
+
+add_task(
+ async function test_storage_local_idb_backend_destroyed_context_promise() {
+ return runWithPrefs(
+ [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]],
+ test_storage_local_call_from_destroying_context
+ );
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js
new file mode 100644
index 0000000000..9bc4281624
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js
@@ -0,0 +1,362 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { ExtensionStorageIDB } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorageIDB.sys.mjs"
+);
+const { getTrimmedString } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionTelemetry.sys.mjs"
+);
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const HISTOGRAM_JSON_IDS = [
+ "WEBEXT_STORAGE_LOCAL_SET_MS",
+ "WEBEXT_STORAGE_LOCAL_GET_MS",
+];
+const KEYED_HISTOGRAM_JSON_IDS = [
+ "WEBEXT_STORAGE_LOCAL_SET_MS_BY_ADDONID",
+ "WEBEXT_STORAGE_LOCAL_GET_MS_BY_ADDONID",
+];
+
+const HISTOGRAM_IDB_IDS = [
+ "WEBEXT_STORAGE_LOCAL_IDB_SET_MS",
+ "WEBEXT_STORAGE_LOCAL_IDB_GET_MS",
+];
+const KEYED_HISTOGRAM_IDB_IDS = [
+ "WEBEXT_STORAGE_LOCAL_IDB_SET_MS_BY_ADDONID",
+ "WEBEXT_STORAGE_LOCAL_IDB_GET_MS_BY_ADDONID",
+];
+
+const HISTOGRAM_IDS = [].concat(HISTOGRAM_JSON_IDS, HISTOGRAM_IDB_IDS);
+const KEYED_HISTOGRAM_IDS = [].concat(
+ KEYED_HISTOGRAM_JSON_IDS,
+ KEYED_HISTOGRAM_IDB_IDS
+);
+
+const EXTENSION_ID1 = "@test-extension1";
+const EXTENSION_ID2 = "@test-extension2";
+
+async function test_telemetry_background() {
+ const expectedEmptyHistograms = ExtensionStorageIDB.isBackendEnabled
+ ? HISTOGRAM_JSON_IDS
+ : HISTOGRAM_IDB_IDS;
+ const expectedEmptyKeyedHistograms = ExtensionStorageIDB.isBackendEnabled
+ ? KEYED_HISTOGRAM_JSON_IDS
+ : KEYED_HISTOGRAM_IDB_IDS;
+
+ const expectedNonEmptyHistograms = ExtensionStorageIDB.isBackendEnabled
+ ? HISTOGRAM_IDB_IDS
+ : HISTOGRAM_JSON_IDS;
+ const expectedNonEmptyKeyedHistograms = ExtensionStorageIDB.isBackendEnabled
+ ? KEYED_HISTOGRAM_IDB_IDS
+ : KEYED_HISTOGRAM_JSON_IDS;
+
+ const server = createHttpServer();
+ server.registerDirectory("/data/", do_get_file("data"));
+
+ const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+ async function contentScript() {
+ await browser.storage.local.set({ a: "b" });
+ await browser.storage.local.get("a");
+ browser.test.sendMessage("contentDone");
+ }
+
+ let baseManifest = {
+ permissions: ["storage"],
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ };
+
+ let baseExtInfo = {
+ async background() {
+ await browser.storage.local.set({ a: "b" });
+ await browser.storage.local.get("a");
+ browser.test.sendMessage("backgroundDone");
+ },
+ files: {
+ "content_script.js": contentScript,
+ },
+ };
+
+ let extension1 = ExtensionTestUtils.loadExtension({
+ ...baseExtInfo,
+ manifest: {
+ ...baseManifest,
+ browser_specific_settings: {
+ gecko: { id: EXTENSION_ID1 },
+ },
+ },
+ });
+ let extension2 = ExtensionTestUtils.loadExtension({
+ ...baseExtInfo,
+ manifest: {
+ ...baseManifest,
+ browser_specific_settings: {
+ gecko: { id: EXTENSION_ID2 },
+ },
+ },
+ });
+
+ clearHistograms();
+
+ let process = IS_OOP ? "extension" : "parent";
+ let snapshots = getSnapshots(process);
+ let keyedSnapshots = getKeyedSnapshots(process);
+
+ for (let id of HISTOGRAM_IDS) {
+ ok(!(id in snapshots), `No data recorded for histogram: ${id}.`);
+ }
+
+ for (let id of KEYED_HISTOGRAM_IDS) {
+ Assert.deepEqual(
+ Object.keys(keyedSnapshots[id] || {}),
+ [],
+ `No data recorded for histogram: ${id}.`
+ );
+ }
+
+ await extension1.startup();
+ await extension1.awaitMessage("backgroundDone");
+ for (let id of expectedNonEmptyHistograms) {
+ await promiseTelemetryRecorded(id, process, 1);
+ }
+ for (let id of expectedNonEmptyKeyedHistograms) {
+ await promiseKeyedTelemetryRecorded(id, process, EXTENSION_ID1, 1);
+ }
+
+ // Telemetry from extension1's background page should be recorded.
+ snapshots = getSnapshots(process);
+ keyedSnapshots = getKeyedSnapshots(process);
+
+ for (let id of expectedNonEmptyHistograms) {
+ equal(
+ valueSum(snapshots[id].values),
+ 1,
+ `Data recorded for histogram: ${id}.`
+ );
+ }
+
+ for (let id of expectedNonEmptyKeyedHistograms) {
+ Assert.deepEqual(
+ Object.keys(keyedSnapshots[id]),
+ [EXTENSION_ID1],
+ `Data recorded for histogram: ${id}.`
+ );
+ equal(
+ valueSum(keyedSnapshots[id][EXTENSION_ID1].values),
+ 1,
+ `Data recorded for histogram: ${id}.`
+ );
+ }
+
+ await extension2.startup();
+ await extension2.awaitMessage("backgroundDone");
+
+ for (let id of expectedNonEmptyHistograms) {
+ await promiseTelemetryRecorded(id, process, 2);
+ }
+ for (let id of expectedNonEmptyKeyedHistograms) {
+ await promiseKeyedTelemetryRecorded(id, process, EXTENSION_ID2, 1);
+ }
+
+ // Telemetry from extension2's background page should be recorded.
+ snapshots = getSnapshots(process);
+ keyedSnapshots = getKeyedSnapshots(process);
+
+ for (let id of expectedNonEmptyHistograms) {
+ equal(
+ valueSum(snapshots[id].values),
+ 2,
+ `Additional data recorded for histogram: ${id}.`
+ );
+ }
+
+ for (let id of expectedNonEmptyKeyedHistograms) {
+ Assert.deepEqual(
+ Object.keys(keyedSnapshots[id]).sort(),
+ [EXTENSION_ID1, EXTENSION_ID2],
+ `Additional data recorded for histogram: ${id}.`
+ );
+ equal(
+ valueSum(keyedSnapshots[id][EXTENSION_ID2].values),
+ 1,
+ `Additional data recorded for histogram: ${id}.`
+ );
+ }
+
+ await extension2.unload();
+
+ // Run a content script.
+ process = IS_OOP ? "content" : "parent";
+ let expectedCount = IS_OOP ? 1 : 3;
+ let expectedKeyedCount = IS_OOP ? 1 : 2;
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+ await extension1.awaitMessage("contentDone");
+
+ for (let id of expectedNonEmptyHistograms) {
+ await promiseTelemetryRecorded(id, process, expectedCount);
+ }
+ for (let id of expectedNonEmptyKeyedHistograms) {
+ await promiseKeyedTelemetryRecorded(
+ id,
+ process,
+ EXTENSION_ID1,
+ expectedKeyedCount
+ );
+ }
+
+ // Telemetry from extension1's content script should be recorded.
+ snapshots = getSnapshots(process);
+ keyedSnapshots = getKeyedSnapshots(process);
+
+ for (let id of expectedNonEmptyHistograms) {
+ equal(
+ valueSum(snapshots[id].values),
+ expectedCount,
+ `Data recorded in content script for histogram: ${id}.`
+ );
+ }
+
+ for (let id of expectedNonEmptyKeyedHistograms) {
+ Assert.deepEqual(
+ Object.keys(keyedSnapshots[id]).sort(),
+ IS_OOP ? [EXTENSION_ID1] : [EXTENSION_ID1, EXTENSION_ID2],
+ `Additional data recorded for histogram: ${id}.`
+ );
+ equal(
+ valueSum(keyedSnapshots[id][EXTENSION_ID1].values),
+ expectedKeyedCount,
+ `Additional data recorded for histogram: ${id}.`
+ );
+ }
+
+ await extension1.unload();
+
+ // Telemetry for histograms that we expect to be empty.
+ for (let id of expectedEmptyHistograms) {
+ ok(!(id in snapshots), `No data recorded for histogram: ${id}.`);
+ }
+
+ for (let id of expectedEmptyKeyedHistograms) {
+ Assert.deepEqual(
+ Object.keys(keyedSnapshots[id] || {}),
+ [],
+ `No data recorded for histogram: ${id}.`
+ );
+ }
+
+ await contentPage.close();
+}
+
+add_task(async function setup() {
+ // Telemetry test setup needed to ensure that the builtin events are defined
+ // and they can be collected and verified.
+ await TelemetryController.testSetup();
+
+ // This is actually only needed on Android, because it does not properly support unified telemetry
+ // and so, if not enabled explicitly here, it would make these tests to fail when running on a
+ // non-Nightly build.
+ const oldCanRecordBase = Services.telemetry.canRecordBase;
+ Services.telemetry.canRecordBase = true;
+ registerCleanupFunction(() => {
+ Services.telemetry.canRecordBase = oldCanRecordBase;
+ });
+});
+
+add_task(function test_telemetry_background_file_backend() {
+ return runWithPrefs(
+ [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]],
+ test_telemetry_background
+ );
+});
+
+add_task(function test_telemetry_background_idb_backend() {
+ return runWithPrefs(
+ [
+ [ExtensionStorageIDB.BACKEND_ENABLED_PREF, true],
+ // Set the migrated preference for the two test extension, because the
+ // first storage.local call fallbacks to run in the parent process when we
+ // don't know which is the selected backend during the extension startup
+ // and so we can't choose the telemetry histogram to use.
+ [
+ `${ExtensionStorageIDB.IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID1}`,
+ true,
+ ],
+ [
+ `${ExtensionStorageIDB.IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID2}`,
+ true,
+ ],
+ ],
+ test_telemetry_background
+ );
+});
+
+// This test verifies that we do record the expected telemetry event when we
+// normalize the error message for an unexpected error (an error raised internally
+// by the QuotaManager and/or IndexedDB, which it is being normalized into the generic
+// "An unexpected error occurred" error message).
+add_task(async function test_telemetry_storage_local_unexpected_error() {
+ // Clear any telemetry events collected so far.
+ Services.telemetry.clearEvents();
+
+ const methods = ["clear", "get", "remove", "set"];
+ const veryLongErrorName = `VeryLongErrorName${Array(200).fill(0).join("")}`;
+ const otherError = new Error("an error recorded as OtherError");
+
+ const recordedErrors = [
+ new DOMException("error message", "UnexpectedDOMException"),
+ new DOMException("error message", veryLongErrorName),
+ otherError,
+ ];
+
+ // We expect the following errors to not be recorded in telemetry (because they
+ // are raised on scenarios that we already expect).
+ const nonRecordedErrors = [
+ new DOMException("error message", "QuotaExceededError"),
+ new DOMException("error message", "DataCloneError"),
+ ];
+
+ const expectedEvents = [];
+
+ const errors = [].concat(recordedErrors, nonRecordedErrors);
+
+ for (let i = 0; i < errors.length; i++) {
+ const error = errors[i];
+ const storageMethod = methods[i] || "set";
+ ExtensionStorageIDB.normalizeStorageError({
+ error: errors[i],
+ extensionId: EXTENSION_ID1,
+ storageMethod,
+ });
+
+ if (recordedErrors.includes(error)) {
+ let error_name =
+ error === otherError ? "OtherError" : getTrimmedString(error.name);
+
+ expectedEvents.push({
+ value: EXTENSION_ID1,
+ object: storageMethod,
+ extra: { error_name },
+ });
+ }
+ }
+
+ await TelemetryTestUtils.assertEvents(expectedEvents, {
+ category: "extensions.data",
+ method: "storageLocalError",
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js
new file mode 100644
index 0000000000..538ce9d8fc
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js
@@ -0,0 +1,97 @@
+"use strict";
+
+add_task(async function test_extension_page_tabs_create_reload_and_close() {
+ let events = [];
+ {
+ const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+ );
+ let record = (type, extensionContext) => {
+ let eventType = type == "proxy-context-load" ? "load" : "unload";
+ let url = extensionContext.uri.spec;
+ let extensionId = extensionContext.extension.id;
+ events.push({ eventType, url, extensionId });
+ };
+
+ Management.on("proxy-context-load", record);
+ Management.on("proxy-context-unload", record);
+ registerCleanupFunction(() => {
+ Management.off("proxy-context-load", record);
+ Management.off("proxy-context-unload", record);
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("tab-url", browser.runtime.getURL("page.html"));
+ },
+ files: {
+ "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`,
+ "page.js"() {
+ browser.test.sendMessage("extension page loaded", document.URL);
+ },
+ },
+ });
+
+ await extension.startup();
+ let tabURL = await extension.awaitMessage("tab-url");
+ events.splice(0);
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(tabURL, {
+ extension,
+ });
+ let extensionPageURL = await extension.awaitMessage("extension page loaded");
+ equal(extensionPageURL, tabURL, "Loaded the expected URL");
+
+ let contextEvents = events.splice(0);
+ equal(contextEvents.length, 1, "ExtensionContext change for opening a tab");
+ equal(contextEvents[0].eventType, "load", "create ExtensionContext for tab");
+ equal(
+ contextEvents[0].url,
+ extensionPageURL,
+ "ExtensionContext URL after tab creation should be tab URL"
+ );
+
+ await contentPage.spawn([], () => {
+ this.content.location.reload();
+ });
+ let extensionPageURL2 = await extension.awaitMessage("extension page loaded");
+
+ equal(
+ extensionPageURL,
+ extensionPageURL2,
+ "The tab's URL is expected to not change after a page reload"
+ );
+
+ contextEvents = events.splice(0);
+ equal(contextEvents.length, 2, "ExtensionContext change after tab reload");
+ equal(contextEvents[0].eventType, "unload", "unload old ExtensionContext");
+ equal(
+ contextEvents[0].url,
+ extensionPageURL,
+ "ExtensionContext URL before reload should be tab URL"
+ );
+ equal(
+ contextEvents[1].eventType,
+ "load",
+ "create new ExtensionContext for tab"
+ );
+ equal(
+ contextEvents[1].url,
+ extensionPageURL2,
+ "ExtensionContext URL after reload should be tab URL"
+ );
+
+ await contentPage.close();
+
+ contextEvents = events.splice(0);
+ equal(contextEvents.length, 1, "ExtensionContext after closing tab");
+ equal(contextEvents[0].eventType, "unload", "unload tab's ExtensionContext");
+ equal(
+ contextEvents[0].url,
+ extensionPageURL2,
+ "ExtensionContext URL at closing tab should be tab URL"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js
new file mode 100644
index 0000000000..c651e46732
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js
@@ -0,0 +1,917 @@
+"use strict";
+
+const { TelemetryArchive } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryArchive.sys.mjs"
+);
+const { TelemetryUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryUtils.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const { TelemetryArchiveTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryArchiveTesting.sys.mjs"
+);
+
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+// All tests run privileged unless otherwise specified not to.
+function createExtension(
+ backgroundScript,
+ permissions,
+ isPrivileged = true,
+ telemetry
+) {
+ let extensionData = {
+ background: backgroundScript,
+ manifest: { permissions, telemetry },
+ isPrivileged,
+ };
+
+ return ExtensionTestUtils.loadExtension(extensionData);
+}
+
+async function run(test) {
+ let extension = createExtension(
+ test.backgroundScript,
+ test.permissions || ["telemetry"],
+ test.isPrivileged,
+ test.telemetry
+ );
+ await extension.startup();
+ await extension.awaitFinish(test.doneSignal);
+ await extension.unload();
+}
+
+// Currently unsupported on Android: blocked on 1220177.
+// See 1280234 c67 for discussion.
+if (AppConstants.MOZ_BUILD_APP === "browser") {
+ add_task(async function test_telemetry_without_telemetry_permission() {
+ await run({
+ backgroundScript: () => {
+ browser.test.assertTrue(
+ !browser.telemetry,
+ "'telemetry' permission is required"
+ );
+ browser.test.notifyPass("telemetry_permission");
+ },
+ permissions: [],
+ doneSignal: "telemetry_permission",
+ isPrivileged: false,
+ });
+ });
+
+ add_task(
+ async function test_telemetry_without_telemetry_permission_privileged() {
+ await run({
+ backgroundScript: () => {
+ browser.test.assertTrue(
+ !browser.telemetry,
+ "'telemetry' permission is required"
+ );
+ browser.test.notifyPass("telemetry_permission");
+ },
+ permissions: [],
+ doneSignal: "telemetry_permission",
+ });
+ }
+ );
+
+ add_task(async function test_telemetry_scalar_add() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarAdd(
+ "telemetry.test.unsigned_int_kind",
+ 1
+ );
+ browser.test.notifyPass("scalar_add");
+ },
+ doneSignal: "scalar_add",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent", false, true),
+ "telemetry.test.unsigned_int_kind",
+ 1
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_add_unknown_name() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarAdd("telemetry.test.does_not_exist", 1);
+ browser.test.notifyPass("scalar_add_unknown_name");
+ },
+ doneSignal: "scalar_add_unknown_name",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) => message.includes("Unknown scalar")),
+ "Telemetry should warn if an unknown scalar is incremented"
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_add_illegal_value() {
+ await run({
+ backgroundScript: () => {
+ browser.test.assertThrows(
+ () =>
+ browser.telemetry.scalarAdd("telemetry.test.unsigned_int_kind", {}),
+ /Incorrect argument types for telemetry.scalarAdd/,
+ "The second 'value' argument to scalarAdd must be an integer, string, or boolean"
+ );
+ browser.test.notifyPass("scalar_add_illegal_value");
+ },
+ doneSignal: "scalar_add_illegal_value",
+ });
+ });
+
+ add_task(async function test_telemetry_scalar_add_invalid_keyed_scalar() {
+ let { messages } = await promiseConsoleOutput(async function () {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarAdd(
+ "telemetry.test.keyed_unsigned_int",
+ 1
+ );
+ browser.test.notifyPass("scalar_add_invalid_keyed_scalar");
+ },
+ doneSignal: "scalar_add_invalid_keyed_scalar",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) =>
+ message.includes("Attempting to manage a keyed scalar as a scalar")
+ ),
+ "Telemetry should warn if a scalarAdd is called for a keyed scalar"
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_set_bool_true() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarSet("telemetry.test.boolean_kind", true);
+ browser.test.notifyPass("scalar_set_bool_true");
+ },
+ doneSignal: "scalar_set_bool_true",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent", false, true),
+ "telemetry.test.boolean_kind",
+ true
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_set_bool_false() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarSet("telemetry.test.boolean_kind", false);
+ browser.test.notifyPass("scalar_set_bool_false");
+ },
+ doneSignal: "scalar_set_bool_false",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent", false, true),
+ "telemetry.test.boolean_kind",
+ false
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_unset_bool() {
+ Services.telemetry.clearScalars();
+ TelemetryTestUtils.assertScalarUnset(
+ TelemetryTestUtils.getProcessScalars("parent", false, true),
+ "telemetry.test.boolean_kind"
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_set_unknown_name() {
+ let { messages } = await promiseConsoleOutput(async function () {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarSet(
+ "telemetry.test.does_not_exist",
+ true
+ );
+ browser.test.notifyPass("scalar_set_unknown_name");
+ },
+ doneSignal: "scalar_set_unknown_name",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) => message.includes("Unknown scalar")),
+ "Telemetry should warn if an unknown scalar is set"
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_set_zero() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarSet(
+ "telemetry.test.unsigned_int_kind",
+ 0
+ );
+ browser.test.notifyPass("scalar_set_zero");
+ },
+ doneSignal: "scalar_set_zero",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent", false, true),
+ "telemetry.test.unsigned_int_kind",
+ 0
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_set_maximum() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarSetMaximum(
+ "telemetry.test.unsigned_int_kind",
+ 123
+ );
+ browser.test.notifyPass("scalar_set_maximum");
+ },
+ doneSignal: "scalar_set_maximum",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent", false, true),
+ "telemetry.test.unsigned_int_kind",
+ 123
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_set_maximum_unknown_name() {
+ let { messages } = await promiseConsoleOutput(async function () {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarSetMaximum(
+ "telemetry.test.does_not_exist",
+ 1
+ );
+ browser.test.notifyPass("scalar_set_maximum_unknown_name");
+ },
+ doneSignal: "scalar_set_maximum_unknown_name",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) => message.includes("Unknown scalar")),
+ "Telemetry should warn if an unknown scalar is set"
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_set_maximum_illegal_value() {
+ await run({
+ backgroundScript: () => {
+ browser.test.assertThrows(
+ () =>
+ browser.telemetry.scalarSetMaximum(
+ "telemetry.test.unsigned_int_kind",
+ "string"
+ ),
+ /Incorrect argument types for telemetry.scalarSetMaximum/,
+ "The second 'value' argument to scalarSetMaximum must be a scalar"
+ );
+ browser.test.notifyPass("scalar_set_maximum_illegal_value");
+ },
+ doneSignal: "scalar_set_maximum_illegal_value",
+ });
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_add() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarAdd(
+ "telemetry.test.keyed_unsigned_int",
+ "foo",
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_add");
+ },
+ doneSignal: "keyed_scalar_add",
+ });
+ TelemetryTestUtils.assertKeyedScalar(
+ TelemetryTestUtils.getProcessScalars("parent", true, true),
+ "telemetry.test.keyed_unsigned_int",
+ "foo",
+ 1
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_add_unknown_name() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarAdd(
+ "telemetry.test.does_not_exist",
+ "foo",
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_add_unknown_name");
+ },
+ doneSignal: "keyed_scalar_add_unknown_name",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) => message.includes("Unknown scalar")),
+ "Telemetry should warn if an unknown keyed scalar is incremented"
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_add_illegal_value() {
+ await run({
+ backgroundScript: () => {
+ browser.test.assertThrows(
+ () =>
+ browser.telemetry.keyedScalarAdd(
+ "telemetry.test.keyed_unsigned_int",
+ "foo",
+ {}
+ ),
+ /Incorrect argument types for telemetry.keyedScalarAdd/,
+ "The second 'value' argument to keyedScalarAdd must be an integer, string, or boolean"
+ );
+ browser.test.notifyPass("keyed_scalar_add_illegal_value");
+ },
+ doneSignal: "keyed_scalar_add_illegal_value",
+ });
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_add_invalid_scalar() {
+ let { messages } = await promiseConsoleOutput(async function () {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarAdd(
+ "telemetry.test.unsigned_int_kind",
+ "foo",
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_add_invalid_scalar");
+ },
+ doneSignal: "keyed_scalar_add_invalid_scalar",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) =>
+ message.includes(
+ "Attempting to manage a keyed scalar as a scalar (or vice-versa)"
+ )
+ ),
+ "Telemetry should warn if a scalar is incremented as a keyed scalar"
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_add_long_key() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarAdd(
+ "telemetry.test.keyed_unsigned_int",
+ "X".repeat(73),
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_add_long_key");
+ },
+ doneSignal: "keyed_scalar_add_long_key",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) =>
+ message.includes("The key length must be limited to 72 characters.")
+ ),
+ "Telemetry should warn if keyed scalar's key is too long"
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_set() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarSet(
+ "telemetry.test.keyed_boolean_kind",
+ "foo",
+ true
+ );
+ browser.test.notifyPass("keyed_scalar_set");
+ },
+ doneSignal: "keyed_scalar_set",
+ });
+ TelemetryTestUtils.assertKeyedScalar(
+ TelemetryTestUtils.getProcessScalars("parent", true, true),
+ "telemetry.test.keyed_boolean_kind",
+ "foo",
+ true
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_set_unknown_name() {
+ let { messages } = await promiseConsoleOutput(async function () {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarSet(
+ "telemetry.test.does_not_exist",
+ "foo",
+ true
+ );
+ browser.test.notifyPass("keyed_scalar_set_unknown_name");
+ },
+ doneSignal: "keyed_scalar_set_unknown_name",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) => message.includes("Unknown scalar")),
+ "Telemetry should warn if an unknown keyed scalar is incremented"
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_set_long_key() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarSet(
+ "telemetry.test.keyed_unsigned_int",
+ "X".repeat(73),
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_set_long_key");
+ },
+ doneSignal: "keyed_scalar_set_long_key",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) =>
+ message.includes("The key length must be limited to 72 characters")
+ ),
+ "Telemetry should warn if keyed scalar's key is too long"
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_set_maximum() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarSetMaximum(
+ "telemetry.test.keyed_unsigned_int",
+ "foo",
+ 123
+ );
+ browser.test.notifyPass("keyed_scalar_set_maximum");
+ },
+ doneSignal: "keyed_scalar_set_maximum",
+ });
+ TelemetryTestUtils.assertKeyedScalar(
+ TelemetryTestUtils.getProcessScalars("parent", true, true),
+ "telemetry.test.keyed_unsigned_int",
+ "foo",
+ 123
+ );
+ });
+
+ add_task(
+ async function test_telemetry_keyed_scalar_set_maximum_unknown_name() {
+ let { messages } = await promiseConsoleOutput(async function () {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarSetMaximum(
+ "telemetry.test.does_not_exist",
+ "foo",
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_set_maximum_unknown_name");
+ },
+ doneSignal: "keyed_scalar_set_maximum_unknown_name",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) => message.includes("Unknown scalar")),
+ "Telemetry should warn if an unknown keyed scalar is set"
+ );
+ }
+ );
+
+ add_task(
+ async function test_telemetry_keyed_scalar_set_maximum_illegal_value() {
+ await run({
+ backgroundScript: () => {
+ browser.test.assertThrows(
+ () =>
+ browser.telemetry.keyedScalarSetMaximum(
+ "telemetry.test.keyed_unsigned_int",
+ "foo",
+ "string"
+ ),
+ /Incorrect argument types for telemetry.keyedScalarSetMaximum/,
+ "The third 'value' argument to keyedScalarSetMaximum must be a scalar"
+ );
+ browser.test.notifyPass("keyed_scalar_set_maximum_illegal_value");
+ },
+ doneSignal: "keyed_scalar_set_maximum_illegal_value",
+ });
+ }
+ );
+
+ add_task(async function test_telemetry_keyed_scalar_set_maximum_long_key() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarSetMaximum(
+ "telemetry.test.keyed_unsigned_int",
+ "X".repeat(73),
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_set_maximum_long_key");
+ },
+ doneSignal: "keyed_scalar_set_maximum_long_key",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) =>
+ message.includes("The key length must be limited to 72 characters")
+ ),
+ "Telemetry should warn if keyed scalar's key is too long"
+ );
+ });
+
+ add_task(async function test_telemetry_record_event() {
+ Services.telemetry.clearEvents();
+ Services.telemetry.setEventRecordingEnabled("telemetry.test", true);
+
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.recordEvent(
+ "telemetry.test",
+ "test1",
+ "object1"
+ );
+ browser.test.notifyPass("record_event_ok");
+ },
+ doneSignal: "record_event_ok",
+ });
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ category: "telemetry.test",
+ method: "test1",
+ object: "object1",
+ },
+ ],
+ { category: "telemetry.test" }
+ );
+
+ Services.telemetry.setEventRecordingEnabled("telemetry.test", false);
+ Services.telemetry.clearEvents();
+ });
+
+ // Bug 1536877
+ add_task(async function test_telemetry_record_event_value_must_be_string() {
+ Services.telemetry.clearEvents();
+ Services.telemetry.setEventRecordingEnabled("telemetry.test", true);
+
+ await run({
+ backgroundScript: async () => {
+ try {
+ await browser.telemetry.recordEvent(
+ "telemetry.test",
+ "test1",
+ "object1",
+ "value1"
+ );
+ browser.test.notifyPass("record_event_string_value");
+ } catch (ex) {
+ browser.test.fail(
+ `Unexpected exception raised during record_event_value_must_be_string: ${ex}`
+ );
+ browser.test.notifyPass("record_event_string_value");
+ throw ex;
+ }
+ },
+ doneSignal: "record_event_string_value",
+ });
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ category: "telemetry.test",
+ method: "test1",
+ object: "object1",
+ value: "value1",
+ },
+ ],
+ { category: "telemetry.test" }
+ );
+
+ Services.telemetry.setEventRecordingEnabled("telemetry.test", false);
+ Services.telemetry.clearEvents();
+ });
+
+ add_task(async function test_telemetry_register_scalars_string() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.registerScalars("telemetry.test.dynamic", {
+ webext_string: {
+ kind: browser.telemetry.ScalarType.STRING,
+ keyed: false,
+ record_on_release: true,
+ },
+ });
+ await browser.telemetry.scalarSet(
+ "telemetry.test.dynamic.webext_string",
+ "hello"
+ );
+ browser.test.notifyPass("register_scalars_string");
+ },
+ doneSignal: "register_scalars_string",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("dynamic", false, true),
+ "telemetry.test.dynamic.webext_string",
+ "hello"
+ );
+ });
+
+ add_task(async function test_telemetry_register_scalars_multiple() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.registerScalars("telemetry.test.dynamic", {
+ webext_string: {
+ kind: browser.telemetry.ScalarType.STRING,
+ keyed: false,
+ record_on_release: true,
+ },
+ webext_string_too: {
+ kind: browser.telemetry.ScalarType.STRING,
+ keyed: false,
+ record_on_release: true,
+ },
+ });
+ await browser.telemetry.scalarSet(
+ "telemetry.test.dynamic.webext_string",
+ "hello"
+ );
+ await browser.telemetry.scalarSet(
+ "telemetry.test.dynamic.webext_string_too",
+ "world"
+ );
+ browser.test.notifyPass("register_scalars_multiple");
+ },
+ doneSignal: "register_scalars_multiple",
+ });
+ const scalars = TelemetryTestUtils.getProcessScalars(
+ "dynamic",
+ false,
+ true
+ );
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ "telemetry.test.dynamic.webext_string",
+ "hello"
+ );
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ "telemetry.test.dynamic.webext_string_too",
+ "world"
+ );
+ });
+
+ add_task(async function test_telemetry_register_scalars_boolean() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.registerScalars("telemetry.test.dynamic", {
+ webext_boolean: {
+ kind: browser.telemetry.ScalarType.BOOLEAN,
+ keyed: false,
+ record_on_release: true,
+ },
+ });
+ await browser.telemetry.scalarSet(
+ "telemetry.test.dynamic.webext_boolean",
+ true
+ );
+ browser.test.notifyPass("register_scalars_boolean");
+ },
+ doneSignal: "register_scalars_boolean",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("dynamic", false, true),
+ "telemetry.test.dynamic.webext_boolean",
+ true
+ );
+ });
+
+ add_task(async function test_telemetry_register_scalars_count() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.registerScalars("telemetry.test.dynamic", {
+ webext_count: {
+ kind: browser.telemetry.ScalarType.COUNT,
+ keyed: false,
+ record_on_release: true,
+ },
+ });
+ await browser.telemetry.scalarSet(
+ "telemetry.test.dynamic.webext_count",
+ 123
+ );
+ browser.test.notifyPass("register_scalars_count");
+ },
+ doneSignal: "register_scalars_count",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("dynamic", false, true),
+ "telemetry.test.dynamic.webext_count",
+ 123
+ );
+ });
+
+ add_task(async function test_telemetry_register_events() {
+ Services.telemetry.clearEvents();
+
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.registerEvents("telemetry.test.dynamic", {
+ test1: {
+ methods: ["test1"],
+ objects: ["object1"],
+ extra_keys: [],
+ },
+ });
+ await browser.telemetry.recordEvent(
+ "telemetry.test.dynamic",
+ "test1",
+ "object1"
+ );
+ browser.test.notifyPass("register_events");
+ },
+ doneSignal: "register_events",
+ });
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ category: "telemetry.test.dynamic",
+ method: "test1",
+ object: "object1",
+ },
+ ],
+ { category: "telemetry.test.dynamic" },
+ { process: "dynamic" }
+ );
+ });
+
+ add_task(async function test_telemetry_submit_ping() {
+ let archiveTester = new TelemetryArchiveTesting.Checker();
+ await archiveTester.promiseInit();
+
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.submitPing("webext-test", {}, {});
+ browser.test.notifyPass("submit_ping");
+ },
+ doneSignal: "submit_ping",
+ });
+
+ await TestUtils.waitForCondition(
+ () => archiveTester.promiseFindPing("webext-test", []),
+ "Failed to find the webext-test ping"
+ );
+ });
+
+ add_task(async function test_telemetry_submit_encrypted_ping() {
+ await run({
+ backgroundScript: async () => {
+ try {
+ await browser.telemetry.submitEncryptedPing(
+ { payload: "encrypted-webext-test" },
+ {
+ schemaName: "schema-name",
+ schemaVersion: 123,
+ }
+ );
+ browser.test.fail(
+ "Expected exception without required manifest entries set."
+ );
+ } catch (e) {
+ browser.test.assertTrue(
+ e,
+ /Encrypted telemetry pings require ping_type and public_key to be set in manifest./
+ );
+ browser.test.notifyPass("submit_encrypted_ping_fail");
+ }
+ },
+ doneSignal: "submit_encrypted_ping_fail",
+ });
+
+ const telemetryManifestEntries = {
+ ping_type: "encrypted-webext-ping",
+ schemaNamespace: "schema-namespace",
+ public_key: {
+ id: "pioneer-dev-20200423",
+ key: {
+ crv: "P-256",
+ kty: "EC",
+ x: "Qqihp7EryDN2-qQ-zuDPDpy5mJD5soFBDZmzPWTmjwk",
+ y: "PiEQVUlywi2bEsA3_5D0VFrCHClCyUlLW52ajYs-5uc",
+ },
+ },
+ };
+
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.submitEncryptedPing(
+ {
+ payload: "encrypted-webext-test",
+ },
+ {
+ schemaName: "schema-name",
+ schemaVersion: 123,
+ }
+ );
+ browser.test.notifyPass("submit_encrypted_ping_pass");
+ },
+ permissions: ["telemetry"],
+ doneSignal: "submit_encrypted_ping_pass",
+ isPrivileged: true,
+ telemetry: telemetryManifestEntries,
+ });
+
+ telemetryManifestEntries.pioneer_id = true;
+ telemetryManifestEntries.study_name = "test123";
+ Services.prefs.setStringPref("toolkit.telemetry.pioneerId", "test123");
+
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.submitEncryptedPing(
+ { payload: "encrypted-webext-test" },
+ {
+ schemaName: "schema-name",
+ schemaVersion: 123,
+ }
+ );
+ browser.test.notifyPass("submit_encrypted_ping_pass");
+ },
+ permissions: ["telemetry"],
+ doneSignal: "submit_encrypted_ping_pass",
+ isPrivileged: true,
+ telemetry: telemetryManifestEntries,
+ });
+
+ let pings;
+ await TestUtils.waitForCondition(async function () {
+ pings = await TelemetryArchive.promiseArchivedPingList();
+ return pings.length >= 3;
+ }, "Wait until we have at least 3 pings in the telemetry archive");
+
+ equal(pings.length, 3);
+ equal(pings[1].type, "encrypted-webext-ping");
+ equal(pings[2].type, "encrypted-webext-ping");
+ });
+
+ add_task(async function test_telemetry_can_upload_enabled() {
+ Services.prefs.setBoolPref(
+ TelemetryUtils.Preferences.FhrUploadEnabled,
+ true
+ );
+
+ await run({
+ backgroundScript: async () => {
+ const result = await browser.telemetry.canUpload();
+ browser.test.assertTrue(result);
+ browser.test.notifyPass("can_upload_enabled");
+ },
+ doneSignal: "can_upload_enabled",
+ });
+
+ Services.prefs.clearUserPref(TelemetryUtils.Preferences.FhrUploadEnabled);
+ });
+
+ add_task(async function test_telemetry_can_upload_disabled() {
+ Services.prefs.setBoolPref(
+ TelemetryUtils.Preferences.FhrUploadEnabled,
+ false
+ );
+
+ await run({
+ backgroundScript: async () => {
+ const result = await browser.telemetry.canUpload();
+ browser.test.assertFalse(result);
+ browser.test.notifyPass("can_upload_disabled");
+ },
+ doneSignal: "can_upload_disabled",
+ });
+
+ Services.prefs.clearUserPref(TelemetryUtils.Preferences.FhrUploadEnabled);
+ });
+}
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js b/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js
new file mode 100644
index 0000000000..81e07d9a9b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js
@@ -0,0 +1,55 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// This test verifies that the extension mocks behave consistently, regardless
+// of test type (xpcshell vs browser test).
+// See also toolkit/components/extensions/test/browser/browser_ext_test_mock.js
+
+// Check the state of the extension object. This should be consistent between
+// browser tests and xpcshell tests.
+async function checkExtensionStartupAndUnload(ext) {
+ await ext.startup();
+ Assert.ok(ext.id, "Extension ID should be available");
+ Assert.ok(ext.uuid, "Extension UUID should be available");
+ await ext.unload();
+ // Once set nothing clears the UUID.
+ Assert.ok(ext.uuid, "Extension UUID exists after unload");
+}
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_MockExtension() {
+ let ext = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {},
+ });
+
+ Assert.equal(ext.constructor.name, "InstallableWrapper", "expected class");
+ Assert.ok(!ext.id, "Extension ID is initially unavailable");
+ Assert.ok(!ext.uuid, "Extension UUID is initially unavailable");
+ await checkExtensionStartupAndUnload(ext);
+ // When useAddonManager is set, AOMExtensionWrapper clears the ID upon unload.
+ // TODO: Fix AOMExtensionWrapper to not clear the ID after unload, and move
+ // this assertion inside |checkExtensionStartupAndUnload| (since then the
+ // behavior will be consistent across all test types).
+ Assert.ok(!ext.id, "Extension ID is cleared after unload");
+});
+
+add_task(async function test_generated_Extension() {
+ let ext = ExtensionTestUtils.loadExtension({
+ manifest: {},
+ });
+
+ Assert.equal(ext.constructor.name, "ExtensionWrapper", "expected class");
+ // Without "useAddonManager", an Extension is generated and their IDs are
+ // immediately available.
+ Assert.ok(ext.id, "Extension ID is initially available");
+ Assert.ok(ext.uuid, "Extension UUID is initially available");
+ await checkExtensionStartupAndUnload(ext);
+ Assert.ok(ext.id, "Extension ID exists after unload");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js b/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js
new file mode 100644
index 0000000000..b4b1b87ee5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js
@@ -0,0 +1,60 @@
+"use strict";
+
+Services.prefs.setBoolPref("extensions.blocklist.enabled", false);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+const TEST_ADDON_ID = "@some-permanent-test-addon";
+
+// Load a permanent extension that eventually unloads the extension immediately
+// after add-on startup, to set the stage as a regression test for bug 1575190.
+add_task(async function setup_wrapper() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id: TEST_ADDON_ID } },
+ },
+ background() {
+ browser.test.sendMessage("started_up");
+ },
+ });
+
+ await AddonTestUtils.promiseStartupManager();
+ await extension.startup();
+ await extension.awaitBackgroundStarted();
+ await AddonTestUtils.promiseShutdownManager();
+
+ // Check message because it is expected to be received while `startup()` was
+ // pending resolution.
+ info("Awaiting expected started_up message 1");
+ await extension.awaitMessage("started_up");
+
+ // Load AddonManager, and unload the extension as soon as it has started.
+ await AddonTestUtils.promiseStartupManager();
+ await extension.awaitBackgroundStarted();
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+
+ // Confirm that the extension has started when promiseStartupManager returned.
+ info("Awaiting expected started_up message 2");
+ await extension.awaitMessage("started_up");
+});
+
+// Check that the add-on from the previous test has indeed been uninstalled.
+add_task(async function restart_addon_manager_after_extension_unload() {
+ await AddonTestUtils.promiseStartupManager();
+ let addon = await AddonManager.getAddonByID(TEST_ADDON_ID);
+ equal(addon, null, "Test add-on should have been removed");
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_theme_experiments.js b/toolkit/components/extensions/test/xpcshell/test_ext_theme_experiments.js
new file mode 100644
index 0000000000..4c3bf7b4d9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_theme_experiments.js
@@ -0,0 +1,109 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged");
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+// This test checks whether the theme experiments work for privileged static themes
+// and are ignored for unprivileged static themes.
+async function test_experiment_static_theme({ privileged }) {
+ let extensionManifest = {
+ theme: {
+ colors: {},
+ images: {},
+ properties: {},
+ },
+ theme_experiment: {
+ colors: {},
+ images: {},
+ properties: {},
+ },
+ };
+
+ const addonId = `${
+ privileged ? "privileged" : "unprivileged"
+ }-static-theme@test-extension`;
+ const themeFiles = {
+ "manifest.json": {
+ name: "test theme",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: { id: addonId },
+ },
+ ...extensionManifest,
+ },
+ };
+
+ const promiseThemeUpdated = TestUtils.topicObserved(
+ "lightweight-theme-styling-update"
+ );
+
+ let themeAddon;
+ const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => {
+ let { addon } = await AddonTestUtils.promiseInstallXPI(themeFiles);
+ // Enable the newly installed static theme.
+ await addon.enable();
+ themeAddon = addon;
+ });
+
+ const themeExperimentNotAllowed = {
+ message: /This extension is not allowed to run theme experiments/,
+ };
+ AddonTestUtils.checkMessages(messages, {
+ forbidden: privileged ? [themeExperimentNotAllowed] : [],
+ expected: privileged ? [] : [themeExperimentNotAllowed],
+ });
+
+ if (privileged) {
+ // ext-theme.js Theme class constructor doesn't call Theme.prototype.load
+ // if the static theme includes theme_experiment but isn't allowed to.
+ info("Wait for theme updated observer service topic to be notified");
+ const [topicSubject] = await promiseThemeUpdated;
+ let themeData = topicSubject.wrappedJSObject;
+ ok(
+ themeData.experiment,
+ "Expect theme experiment property to be defined in theme update data"
+ );
+ }
+
+ const policy = WebExtensionPolicy.getByID(themeAddon.id);
+ equal(
+ policy.extension.isPrivileged,
+ privileged,
+ `The static theme should be ${privileged ? "privileged" : "unprivileged"}`
+ );
+
+ await themeAddon.uninstall();
+}
+
+add_task(function test_privileged_theme() {
+ return test_experiment_static_theme({ privileged: true });
+});
+
+add_task(
+ {
+ // Some builds (e.g. thunderbird) have experiments enabled by default.
+ pref_set: [["extensions.experiments.enabled", false]],
+ },
+ function test_unprivileged_theme() {
+ return test_experiment_static_theme({ privileged: false });
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js b/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js
new file mode 100644
index 0000000000..f509ae1749
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js
@@ -0,0 +1,20 @@
+"use strict";
+
+/**
+ * This test is asserting that moz-extension: URLs are recognized as trustworthy local origins
+ */
+
+add_task(
+ function test_isOriginPotentiallyTrustworthnsIContentSecurityManagery() {
+ let uri = NetUtil.newURI("moz-extension://foobar/something.html");
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+ Assert.equal(
+ principal.isOriginPotentiallyTrustworthy,
+ true,
+ "it is potentially trustworthy"
+ );
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js
new file mode 100644
index 0000000000..aa4a309a52
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js
@@ -0,0 +1,60 @@
+"use strict";
+
+// This test expects and checks warnings for unknown permissions.
+ExtensionTestUtils.failOnSchemaWarnings(false);
+
+add_task(async function test_unknown_permissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "activeTab",
+ "fooUnknownPermission",
+ "http://*/",
+ "chrome://favicon/",
+ ],
+ optional_permissions: ["chrome://favicon/", "https://example.com/"],
+ },
+ });
+
+ let { messages } = await promiseConsoleOutput(() => extension.startup());
+
+ const { WebExtensionPolicy } = Cu.getGlobalForObject(
+ ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs")
+ );
+
+ let policy = WebExtensionPolicy.getByID(extension.id);
+ Assert.deepEqual(Array.from(policy.permissions).sort(), ["activeTab"]);
+
+ Assert.deepEqual(extension.extension.manifest.optional_permissions, [
+ "https://example.com/",
+ ]);
+
+ ok(
+ messages.some(message =>
+ /Error processing permissions\.1: Value "fooUnknownPermission" must/.test(
+ message
+ )
+ ),
+ 'Got expected error for "fooUnknownPermission"'
+ );
+
+ ok(
+ messages.some(message =>
+ /Error processing permissions\.3: Value "chrome:\/\/favicon\/" must/.test(
+ message
+ )
+ ),
+ 'Got expected error for "chrome://favicon/"'
+ );
+
+ ok(
+ messages.some(message =>
+ /Error processing optional_permissions\.0: Value "chrome:\/\/favicon\/" must/.test(
+ message
+ )
+ ),
+ "Got expected error from optional_permissions"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js b/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js
new file mode 100644
index 0000000000..77eb0c89f7
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js
@@ -0,0 +1,211 @@
+"use strict";
+
+const {
+ createAppInfo,
+ promiseStartupManager,
+ promiseRestartManager,
+ promiseWebExtensionStartup,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+
+const STORAGE_SITE_PERMISSIONS = [
+ "WebExtensions-unlimitedStorage",
+ "persistent-storage",
+];
+
+function checkSitePermissions(principal, expectedPermAction, assertMessage) {
+ for (const permName of STORAGE_SITE_PERMISSIONS) {
+ const actualPermAction = Services.perms.testPermissionFromPrincipal(
+ principal,
+ permName
+ );
+
+ equal(
+ actualPermAction,
+ expectedPermAction,
+ `The extension "${permName}" SitePermission ${assertMessage} as expected`
+ );
+ }
+}
+
+add_task(async function test_unlimitedStorage_restored_on_app_startup() {
+ const id = "test-unlimitedStorage-removed-on-app-shutdown@mozilla";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["unlimitedStorage"],
+ browser_specific_settings: {
+ gecko: { id },
+ },
+ },
+
+ useAddonManager: "permanent",
+ });
+
+ await promiseStartupManager();
+ await extension.startup();
+
+ const policy = WebExtensionPolicy.getByID(extension.id);
+ const principal = policy.extension.principal;
+
+ checkSitePermissions(
+ principal,
+ Services.perms.ALLOW_ACTION,
+ "has been allowed"
+ );
+
+ // Remove site permissions as it would happen if Firefox is shutting down
+ // with the "clear site permissions" setting.
+
+ Services.perms.removeFromPrincipal(
+ principal,
+ "WebExtensions-unlimitedStorage"
+ );
+ Services.perms.removeFromPrincipal(principal, "persistent-storage");
+
+ checkSitePermissions(principal, Services.perms.UNKNOWN_ACTION, "is not set");
+
+ const onceExtensionStarted = promiseWebExtensionStartup(id);
+ await promiseRestartManager();
+ await onceExtensionStarted;
+
+ // The site permissions should have been granted again.
+ checkSitePermissions(
+ principal,
+ Services.perms.ALLOW_ACTION,
+ "has been allowed"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_unlimitedStorage_removed_on_update() {
+ const id = "test-unlimitedStorage-removed-on-update@mozilla";
+
+ function background() {
+ browser.test.onMessage.addListener(async msg => {
+ switch (msg) {
+ case "set-storage":
+ browser.test.log(`storing data in storage.local`);
+ await browser.storage.local.set({ akey: "somevalue" });
+ browser.test.log(`data stored in storage.local successfully`);
+ break;
+ case "has-storage": {
+ browser.test.log(`checking data stored in storage.local`);
+ const data = await browser.storage.local.get(["akey"]);
+ browser.test.assertEq(
+ data.akey,
+ "somevalue",
+ "Got storage.local data"
+ );
+ break;
+ }
+ default:
+ browser.test.fail(`Unexpected test message: ${msg}`);
+ }
+
+ browser.test.sendMessage(`${msg}:done`);
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["unlimitedStorage", "storage"],
+ browser_specific_settings: { gecko: { id } },
+ version: "1",
+ },
+ useAddonManager: "permanent",
+ });
+
+ await extension.startup();
+
+ const policy = WebExtensionPolicy.getByID(extension.id);
+ const principal = policy.extension.principal;
+
+ checkSitePermissions(
+ principal,
+ Services.perms.ALLOW_ACTION,
+ "has been allowed"
+ );
+
+ extension.sendMessage("set-storage");
+ await extension.awaitMessage("set-storage:done");
+ extension.sendMessage("has-storage");
+ await extension.awaitMessage("has-storage:done");
+
+ // Simulate an update which do not require the unlimitedStorage permission.
+ await extension.upgrade({
+ background,
+ manifest: {
+ permissions: ["storage"],
+ browser_specific_settings: { gecko: { id } },
+ version: "2",
+ },
+ useAddonManager: "permanent",
+ });
+
+ const newPolicy = WebExtensionPolicy.getByID(extension.id);
+ const newPrincipal = newPolicy.extension.principal;
+
+ equal(
+ principal.spec,
+ newPrincipal.spec,
+ "upgraded extension has the expected principal"
+ );
+
+ checkSitePermissions(
+ principal,
+ Services.perms.UNKNOWN_ACTION,
+ "has been cleared"
+ );
+
+ // Verify that the previously stored data has not been
+ // removed as a side effect of removing the unlimitedStorage
+ // permission.
+ extension.sendMessage("has-storage");
+ await extension.awaitMessage("has-storage:done");
+
+ await extension.unload();
+});
+
+add_task(async function test_unlimitedStorage_origin_attributes() {
+ Services.prefs.setBoolPref("privacy.firstparty.isolate", true);
+
+ const id = "test-unlimitedStorage-origin-attributes@mozilla";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["unlimitedStorage"],
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ await extension.startup();
+
+ let policy = WebExtensionPolicy.getByID(extension.id);
+ let principal = policy.extension.principal;
+
+ ok(
+ !principal.firstPartyDomain,
+ "extension principal has no firstPartyDomain"
+ );
+
+ let perm = Services.perms.testExactPermissionFromPrincipal(
+ principal,
+ "persistent-storage"
+ );
+ equal(
+ perm,
+ Services.perms.ALLOW_ACTION,
+ "Should have the correct permission without OAs"
+ );
+
+ await extension.unload();
+
+ Services.prefs.clearUserPref("privacy.firstparty.isolate");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js b/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js
new file mode 100644
index 0000000000..fee33e8815
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js
@@ -0,0 +1,230 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+// Background and content script for testSendMessage_*
+function sendMessage_background(delayedNotifyPass) {
+ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
+ browser.test.assertEq("from frame", msg, "Expected message from frame");
+ sendResponse("msg from back"); // Should not throw or anything like that.
+ delayedNotifyPass("Received sendMessage from closing frame");
+ });
+}
+function sendMessage_contentScript(testType) {
+ browser.runtime.sendMessage("from frame", reply => {
+ // The frame has been removed, so we should not get this callback!
+ browser.test.fail(`Unexpected reply: ${reply}`);
+ });
+ if (testType == "frame") {
+ frameElement.remove();
+ } else {
+ browser.test.sendMessage("close-window");
+ }
+}
+
+// Background and content script for testConnect_*
+function connect_background(delayedNotifyPass) {
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertEq("port from frame", port.name);
+
+ let disconnected = false;
+ let hasMessage = false;
+ port.onDisconnect.addListener(() => {
+ browser.test.assertFalse(disconnected, "onDisconnect should fire once");
+ disconnected = true;
+ browser.test.assertTrue(
+ hasMessage,
+ "Expected onMessage before onDisconnect"
+ );
+ browser.test.assertEq(
+ null,
+ port.error,
+ "The port is implicitly closed without errors when the other context unloads"
+ );
+ delayedNotifyPass("Received onDisconnect from closing frame");
+ });
+ port.onMessage.addListener(msg => {
+ browser.test.assertFalse(hasMessage, "onMessage should fire once");
+ hasMessage = true;
+ browser.test.assertFalse(
+ disconnected,
+ "Should get message before disconnect"
+ );
+ browser.test.assertEq("from frame", msg, "Expected message from frame");
+ });
+
+ port.postMessage("reply to closing frame");
+ });
+}
+function connect_contentScript(testType) {
+ let isUnloading = false;
+ addEventListener(
+ "pagehide",
+ () => {
+ isUnloading = true;
+ },
+ { once: true }
+ );
+
+ let port = browser.runtime.connect({ name: "port from frame" });
+ port.onMessage.addListener(msg => {
+ // The background page sends a reply as soon as we call runtime.connect().
+ // It is possible that the reply reaches this frame before the
+ // window.close() request has been processed.
+ if (!isUnloading) {
+ browser.test.log(
+ `Ignorting unexpected reply ("${msg}") because the page is not being unloaded.`
+ );
+ return;
+ }
+
+ // The frame has been removed, so we should not get a reply.
+ browser.test.fail(`Unexpected reply: ${msg}`);
+ });
+ port.postMessage("from frame");
+
+ // Removing the frame or window should disconnect the port.
+ if (testType == "frame") {
+ frameElement.remove();
+ } else {
+ browser.test.sendMessage("close-window");
+ }
+}
+
+// `testType` is "window" or "frame".
+function createTestExtension(testType, backgroundScript, contentScript) {
+ // Make a roundtrip between the background page and the test runner (which is
+ // in the same process as the content script) to make sure that we record a
+ // failure in case the content script's sendMessage or onMessage handlers are
+ // called even after the frame or window was removed.
+ function delayedNotifyPass(msg) {
+ browser.test.onMessage.addListener((type, echoMsg) => {
+ if (type == "pong") {
+ browser.test.assertEq(msg, echoMsg, "Echoed reply should be the same");
+ browser.test.notifyPass(msg);
+ }
+ });
+ browser.test.log("Starting ping-pong to flush messages...");
+ browser.test.sendMessage("ping", msg);
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})(${delayedNotifyPass});`,
+ manifest: {
+ content_scripts: [
+ {
+ js: ["contentscript.js"],
+ all_frames: testType == "frame",
+ matches: ["http://example.com/data/file_sample.html"],
+ },
+ ],
+ },
+ files: {
+ "contentscript.js": `(${contentScript})("${testType}");`,
+ },
+ });
+ extension.awaitMessage("ping").then(msg => {
+ extension.sendMessage("pong", msg);
+ });
+ return extension;
+}
+
+add_task(async function testSendMessage_and_remove_frame() {
+ let extension = createTestExtension(
+ "frame",
+ sendMessage_background,
+ sendMessage_contentScript
+ );
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+
+ await contentPage.spawn([], () => {
+ let { document } = this.content;
+ let frame = document.createElement("iframe");
+ frame.src = "/data/file_sample.html";
+ document.body.appendChild(frame);
+ });
+
+ await extension.awaitFinish("Received sendMessage from closing frame");
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function testConnect_and_remove_frame() {
+ let extension = createTestExtension(
+ "frame",
+ connect_background,
+ connect_contentScript
+ );
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+
+ await contentPage.spawn([], () => {
+ let { document } = this.content;
+ let frame = document.createElement("iframe");
+ frame.src = "/data/file_sample.html";
+ document.body.appendChild(frame);
+ });
+
+ await extension.awaitFinish("Received onDisconnect from closing frame");
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function testSendMessage_and_remove_window() {
+ if (AppConstants.MOZ_BUILD_APP !== "browser") {
+ // We can't rely on this timing on Android.
+ return;
+ }
+
+ let extension = createTestExtension(
+ "window",
+ sendMessage_background,
+ sendMessage_contentScript
+ );
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+ await extension.awaitMessage("close-window");
+ await contentPage.close();
+
+ await extension.awaitFinish("Received sendMessage from closing frame");
+ await extension.unload();
+});
+
+add_task(async function testConnect_and_remove_window() {
+ if (AppConstants.MOZ_BUILD_APP !== "browser") {
+ // We can't rely on this timing on Android.
+ return;
+ }
+
+ let extension = createTestExtension(
+ "window",
+ connect_background,
+ connect_contentScript
+ );
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+ await extension.awaitMessage("close-window");
+ await contentPage.close();
+
+ await extension.awaitFinish("Received onDisconnect from closing frame");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
new file mode 100644
index 0000000000..a44711e26d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
@@ -0,0 +1,729 @@
+"use strict";
+
+const PROCESS_COUNT_PREF = "dom.ipc.processCount";
+
+const { createAppInfo } = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function setup_test_environment() {
+ if (ExtensionTestUtils.remoteContentScripts) {
+ // Start with one content process so that we can increase the number
+ // later and test the behavior of a fresh content process.
+ Services.prefs.setIntPref(PROCESS_COUNT_PREF, 1);
+ }
+
+ // Grant the optional permissions requested.
+ function permissionObserver(subject, topic, data) {
+ if (topic == "webextension-optional-permission-prompt") {
+ let { resolve } = subject.wrappedJSObject;
+ resolve(true);
+ }
+ }
+ Services.obs.addObserver(
+ permissionObserver,
+ "webextension-optional-permission-prompt"
+ );
+ registerCleanupFunction(() => {
+ Services.obs.removeObserver(
+ permissionObserver,
+ "webextension-optional-permission-prompt"
+ );
+ });
+});
+
+// Test that there is no userScripts API namespace when the manifest doesn't include a user_scripts
+// property.
+add_task(async function test_userScripts_manifest_property_required() {
+ function background() {
+ browser.test.assertEq(
+ undefined,
+ browser.userScripts,
+ "userScripts API namespace should be undefined in the extension page"
+ );
+ browser.test.sendMessage("background-page:done");
+ }
+
+ async function contentScript() {
+ browser.test.assertEq(
+ undefined,
+ browser.userScripts,
+ "userScripts API namespace should be undefined in the content script"
+ );
+ browser.test.sendMessage("content-script:done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["http://*/*/file_sample.html"],
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-page:done");
+
+ let url = `${BASE_URL}/file_sample.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+
+ await extension.awaitMessage("content-script:done");
+
+ await extension.unload();
+ await contentPage.close();
+});
+
+// Test that userScripts can only matches origins that are subsumed by the extension permissions,
+// and that more origins can be allowed by requesting an optional permission.
+add_task(async function test_userScripts_matches_denied() {
+ async function background() {
+ async function registerUserScriptWithMatches(matches) {
+ const scripts = await browser.userScripts.register({
+ js: [{ code: "" }],
+ matches,
+ });
+ await scripts.unregister();
+ }
+
+ // These matches are supposed to be denied until the extension has been granted the
+ // <all_urls> origin permission.
+ const testMatches = [
+ "<all_urls>",
+ "file://*/*",
+ "https://localhost/*",
+ "http://example.com/*",
+ ];
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "test-denied-matches") {
+ for (let testMatch of testMatches) {
+ await browser.test.assertRejects(
+ registerUserScriptWithMatches([testMatch]),
+ /Permission denied to register a user script for/,
+ "Got the expected rejection when the extension permission does not subsume the userScript matches"
+ );
+ }
+ } else if (msg === "grant-all-urls") {
+ await browser.permissions.request({ origins: ["<all_urls>"] });
+ } else if (msg === "test-allowed-matches") {
+ for (let testMatch of testMatches) {
+ try {
+ await registerUserScriptWithMatches([testMatch]);
+ } catch (err) {
+ browser.test.fail(
+ `Unexpected rejection ${err} on matching ${JSON.stringify(
+ testMatch
+ )}`
+ );
+ }
+ }
+ } else {
+ browser.test.fail(`Received an unexpected ${msg} test message`);
+ }
+
+ browser.test.sendMessage(`${msg}:done`);
+ });
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://localhost/*"],
+ optional_permissions: ["<all_urls>"],
+ user_scripts: {},
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("background-ready");
+
+ // Test that the matches not subsumed by the extension permissions are being denied.
+ extension.sendMessage("test-denied-matches");
+ await extension.awaitMessage("test-denied-matches:done");
+
+ // Grant the optional <all_urls> permission.
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("grant-all-urls");
+ await extension.awaitMessage("grant-all-urls:done");
+ });
+
+ // Test that all the matches are now subsumed by the extension permissions.
+ extension.sendMessage("test-allowed-matches");
+ await extension.awaitMessage("test-allowed-matches:done");
+
+ await extension.unload();
+});
+
+// Test that userScripts sandboxes:
+// - can be registered/unregistered from an extension page (and they are registered on both new and
+// existing processes).
+// - have no WebExtensions APIs available
+// - are able to access the target window and document
+add_task(async function test_userScripts_no_webext_apis() {
+ async function background() {
+ const matches = ["http://localhost/*/file_sample.html*"];
+
+ const sharedCode = {
+ code: 'console.log("js code shared by multiple userScripts");',
+ };
+
+ const userScriptOptions = {
+ js: [
+ sharedCode,
+ {
+ code: `
+ window.addEventListener("load", () => {
+ const webextAPINamespaces = this.browser ? Object.keys(this.browser) : undefined;
+ document.body.innerHTML = "userScript loaded - " + JSON.stringify(webextAPINamespaces);
+ }, {once: true});
+ `,
+ },
+ ],
+ runAt: "document_start",
+ matches,
+ scriptMetadata: {
+ name: "test-user-script",
+ arrayProperty: ["el1"],
+ objectProperty: { nestedProp: "nestedValue" },
+ nullProperty: null,
+ },
+ };
+
+ let script = await browser.userScripts.register(userScriptOptions);
+
+ // Unregister and then register the same js code again, to verify that the last registered
+ // userScript doesn't get assigned a revoked blob url (otherwise Extensioncontent.jsm
+ // ScriptCache raises an error because it fails to compile the revoked blob url and the user
+ // script will never be loaded).
+ script.unregister();
+ script = await browser.userScripts.register(userScriptOptions);
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg !== "register-new-script") {
+ return;
+ }
+
+ await script.unregister();
+ await browser.userScripts.register({
+ ...userScriptOptions,
+ scriptMetadata: { name: "test-new-script" },
+ js: [
+ sharedCode,
+ {
+ code: `
+ window.addEventListener("load", () => {
+ const webextAPINamespaces = this.browser ? Object.keys(this.browser) : undefined;
+ document.body.innerHTML = "new userScript loaded - " + JSON.stringify(webextAPINamespaces);
+ }, {once: true});
+ `,
+ },
+ ],
+ });
+
+ browser.test.sendMessage("script-registered");
+ });
+
+ const scriptToRemove = await browser.userScripts.register({
+ js: [
+ sharedCode,
+ {
+ code: `
+ window.addEventListener("load", () => {
+ document.body.innerHTML = "unexpected unregistered userScript loaded";
+ }, {once: true});
+ `,
+ },
+ ],
+ runAt: "document_start",
+ matches,
+ scriptMetadata: {
+ name: "user-script-to-remove",
+ },
+ });
+
+ browser.test.assertTrue(
+ "unregister" in script,
+ "Got an unregister method on the userScript API object"
+ );
+
+ // Remove the last registered user script.
+ await scriptToRemove.unregister();
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: ["http://localhost/*/file_sample.html"],
+ user_scripts: {},
+ },
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ await extension.awaitMessage("background-ready");
+
+ let url = `${BASE_URL}/file_sample.html?testpage=1`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ url,
+ ExtensionTestUtils.remoteContentScripts ? { remote: true } : undefined
+ );
+ let result = await contentPage.spawn([], async () => {
+ return {
+ textContent: this.content.document.body.textContent,
+ url: this.content.location.href,
+ readyState: this.content.document.readyState,
+ };
+ });
+ Assert.deepEqual(
+ result,
+ {
+ textContent: "userScript loaded - undefined",
+ url,
+ readyState: "complete",
+ },
+ "The userScript executed on the expected url and no access to the WebExtensions APIs"
+ );
+
+ // If the tests is running with "remote content process" mode, test that the userScript
+ // are being correctly registered in newly created processes (received as part of the sharedData).
+ if (ExtensionTestUtils.remoteContentScripts) {
+ info(
+ "Test content script are correctly created on a newly created process"
+ );
+
+ await extension.sendMessage("register-new-script");
+ await extension.awaitMessage("script-registered");
+
+ // Update the process count preference, so that we can test that the newly registered user script
+ // is propagated as expected into the newly created process.
+ Services.prefs.setIntPref(PROCESS_COUNT_PREF, 2);
+
+ const url2 = `${BASE_URL}/file_sample.html?testpage=2`;
+ let contentPage2 = await ExtensionTestUtils.loadContentPage(url2, {
+ remote: true,
+ });
+ let result2 = await contentPage2.spawn([], async () => {
+ return {
+ textContent: this.content.document.body.textContent,
+ url: this.content.location.href,
+ readyState: this.content.document.readyState,
+ };
+ });
+ Assert.deepEqual(
+ result2,
+ {
+ textContent: "new userScript loaded - undefined",
+ url: url2,
+ readyState: "complete",
+ },
+ "The userScript executed on the expected url and no access to the WebExtensions APIs"
+ );
+
+ await contentPage2.close();
+ }
+
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+// This test verify that a cached script is still able to catch the document
+// while it is still loading (when we do not block the document parsing as
+// we do for a non cached script).
+add_task(async function test_cached_userScript_on_document_start() {
+ function apiScript() {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ sendTestMessage(name, params) {
+ return browser.test.sendMessage(name, params);
+ },
+ });
+ });
+ }
+
+ async function background() {
+ function userScript() {
+ this.sendTestMessage("user-script-loaded", {
+ url: window.location.href,
+ documentReadyState: document.readyState,
+ });
+ }
+
+ await browser.userScripts.register({
+ js: [
+ {
+ code: `(${userScript})();`,
+ },
+ ],
+ runAt: "document_start",
+ matches: ["http://localhost/*/file_sample.html"],
+ });
+
+ browser.test.sendMessage("user-script-registered");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://localhost/*/file_sample.html"],
+ user_scripts: {
+ api_script: "api-script.js",
+ // The following is an unexpected manifest property, that we expect to be ignored and
+ // to not prevent the test extension from being installed and run as expected.
+ unexpected_manifest_key: "test-unexpected-key",
+ },
+ },
+ background,
+ files: {
+ "api-script.js": apiScript,
+ },
+ });
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ await extension.awaitMessage("user-script-registered");
+
+ let url = `${BASE_URL}/file_sample.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+
+ let msg = await extension.awaitMessage("user-script-loaded");
+ Assert.deepEqual(
+ msg,
+ {
+ url,
+ documentReadyState: "loading",
+ },
+ "Got the expected url and document.readyState from a non cached user script"
+ );
+
+ // Reload the page and check that the cached content script is still able to
+ // run on document_start.
+ await contentPage.loadURL(url);
+
+ let msgFromCached = await extension.awaitMessage("user-script-loaded");
+ Assert.deepEqual(
+ msgFromCached,
+ {
+ url,
+ documentReadyState: "loading",
+ },
+ "Got the expected url and document.readyState from a cached user script"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_userScripts_pref_disabled() {
+ async function run_userScript_on_pref_disabled_test() {
+ async function background() {
+ let promise = (async () => {
+ await browser.userScripts.register({
+ js: [
+ {
+ code: "throw new Error('This userScripts should not be registered')",
+ },
+ ],
+ runAt: "document_start",
+ matches: ["<all_urls>"],
+ });
+ })();
+
+ await browser.test.assertRejects(
+ promise,
+ /userScripts APIs are currently experimental/,
+ "Got the expected error from userScripts.register when the userScripts API is disabled"
+ );
+
+ browser.test.sendMessage("background-page:done");
+ }
+
+ async function contentScript() {
+ let promise = (async () => {
+ browser.userScripts.onBeforeScript.addListener(() => {});
+ })();
+ await browser.test.assertRejects(
+ promise,
+ /userScripts APIs are currently experimental/,
+ "Got the expected error from userScripts.onBeforeScript when the userScripts API is disabled"
+ );
+
+ browser.test.sendMessage("content-script:done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["http://*/*/file_sample.html"],
+ user_scripts: { api_script: "" },
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("background-page:done");
+
+ let url = `${BASE_URL}/file_sample.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+
+ await extension.awaitMessage("content-script:done");
+
+ await extension.unload();
+ await contentPage.close();
+ }
+
+ await runWithPrefs(
+ [["extensions.webextensions.userScripts.enabled", false]],
+ run_userScript_on_pref_disabled_test
+ );
+});
+
+// This test verify that userScripts.onBeforeScript API Event is not available without
+// a "user_scripts.api_script" property in the manifest.
+add_task(async function test_user_script_api_script_required() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ },
+ ],
+ user_scripts: {},
+ },
+ files: {
+ "content_script.js": function () {
+ browser.test.assertEq(
+ undefined,
+ browser.userScripts && browser.userScripts.onBeforeScript,
+ "Got an undefined onBeforeScript property as expected"
+ );
+ browser.test.sendMessage("no-onBeforeScript:done");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let url = `${BASE_URL}/file_sample.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+
+ await extension.awaitMessage("no-onBeforeScript:done");
+
+ await extension.unload();
+ await contentPage.close();
+});
+
+add_task(async function test_scriptMetaData() {
+ function getTestCases(isUserScriptsRegister) {
+ return [
+ // When scriptMetadata is not set (or undefined), it is treated as if it were null.
+ // In the API script, the metadata is then expected to be null.
+ isUserScriptsRegister ? undefined : null,
+
+ // Falsey
+ null,
+ "",
+ false,
+ 0,
+
+ // Truthy
+ true,
+ 1,
+ "non-empty string",
+
+ // Objects
+ ["some array with value"],
+ { "some object": "with value" },
+ ];
+ }
+
+ async function background() {
+ for (let scriptMetadata of getTestCases(true)) {
+ await browser.userScripts.register({
+ js: [{ file: "userscript.js" }],
+ runAt: "document_end",
+ matches: ["http://localhost/*/file_sample.html"],
+ scriptMetadata,
+ });
+ }
+
+ browser.test.sendMessage("background-page:done");
+ }
+
+ function apiScript() {
+ let testCases = getTestCases(false);
+ let i = 0;
+
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ checkMetadata() {
+ let expectation = testCases[i];
+ let metadata = script.metadata;
+ if (typeof expectation === "object" && expectation !== null) {
+ // Non-primitive values cannot be compared with assertEq,
+ // so serialize both and just verify that they are equal.
+ expectation = JSON.stringify(expectation);
+ metadata = JSON.stringify(script.metadata);
+ }
+
+ browser.test.assertEq(
+ expectation,
+ metadata,
+ `Expected metadata at call ${i}`
+ );
+ if (++i === testCases.length) {
+ browser.test.sendMessage("apiscript:done");
+ }
+ },
+ });
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `${getTestCases};(${background})()`,
+ manifest: {
+ permissions: ["http://*/*/file_sample.html"],
+ user_scripts: {
+ api_script: "apiscript.js",
+ },
+ },
+ files: {
+ "apiscript.js": `${getTestCases};(${apiScript})()`,
+ "userscript.js": "checkMetadata();",
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("background-page:done");
+
+ const pageUrl = `${BASE_URL}/file_sample.html`;
+ info(`Load content page: ${pageUrl}`);
+ const page = await ExtensionTestUtils.loadContentPage(pageUrl);
+
+ await extension.awaitMessage("apiscript:done");
+
+ await page.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_userScriptOptions_js_property_required() {
+ function background() {
+ const userScriptOptions = {
+ runAt: "document_start",
+ matches: ["http://*/*/file_sample.html"],
+ };
+
+ browser.test.assertThrows(
+ () => browser.userScripts.register(userScriptOptions),
+ /Type error for parameter userScriptOptions \(Property \"js\" is required\)/,
+ "Got the expected error from userScripts.register when js property is missing"
+ );
+
+ browser.test.sendMessage("done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["http://*/*/file_sample.html"],
+ user_scripts: {},
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_userScripts_are_unregistered_on_unload() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://*/*/file_sample.html"],
+ user_scripts: {
+ api_script: "api_script.js",
+ },
+ },
+ files: {
+ "userscript.js": "",
+ "extpage.html": `<!DOCTYPE html><script src="extpage.js"></script>`,
+ "extpage.js": async function extPage() {
+ await browser.userScripts.register({
+ js: [{ file: "userscript.js" }],
+ matches: ["http://localhost/*/file_sample.html"],
+ });
+
+ browser.test.sendMessage("user-script-registered");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ equal(
+ // In order to read the `registeredContentScripts` map, we need to access
+ // the extension embedded in the `ExtensionWrapper` first.
+ extension.extension.registeredContentScripts.size,
+ 0,
+ "no user scripts registered yet"
+ );
+
+ const url = `moz-extension://${extension.uuid}/extpage.html`;
+ info(`loading extension page: ${url}`);
+ const page = await ExtensionTestUtils.loadContentPage(url);
+
+ info("waiting for the user script to be registered");
+ await extension.awaitMessage("user-script-registered");
+
+ equal(
+ extension.extension.registeredContentScripts.size,
+ 1,
+ "got registered user scripts in the extension content scripts map"
+ );
+
+ await page.close();
+
+ equal(
+ extension.extension.registeredContentScripts.size,
+ 0,
+ "user scripts unregistered from the extension content scripts map"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js
new file mode 100644
index 0000000000..5950377f85
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js
@@ -0,0 +1,1108 @@
+"use strict";
+
+const { createAppInfo } = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+// A small utility function used to test the expected behaviors of the userScripts API method
+// wrapper.
+async function test_userScript_APIMethod({
+ apiScript,
+ userScript,
+ userScriptMetadata,
+ testFn,
+ runtimeMessageListener,
+}) {
+ async function backgroundScript(
+ userScriptFn,
+ scriptMetadata,
+ messageListener
+ ) {
+ await browser.userScripts.register({
+ js: [
+ {
+ code: `(${userScriptFn})();`,
+ },
+ ],
+ runAt: "document_end",
+ matches: ["http://localhost/*/file_sample.html"],
+ scriptMetadata,
+ });
+
+ if (messageListener) {
+ browser.runtime.onMessage.addListener(messageListener);
+ }
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ function notifyFinish(failureReason) {
+ browser.test.assertEq(
+ undefined,
+ failureReason,
+ "should be completed without errors"
+ );
+ browser.test.sendMessage("test_userScript_APIMethod:done");
+ }
+
+ function assertTrue(val, message) {
+ browser.test.assertTrue(val, message);
+ if (!val) {
+ browser.test.sendMessage("test_userScript_APIMethod:done");
+ throw message;
+ }
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://localhost/*/file_sample.html"],
+ user_scripts: {
+ api_script: "api-script.js",
+ },
+ },
+ // Defines a background script that receives all the needed test parameters.
+ background: `
+ const metadata = ${JSON.stringify(userScriptMetadata)};
+ (${backgroundScript})(${userScript}, metadata, ${runtimeMessageListener})
+ `,
+ files: {
+ "api-script.js": `(${apiScript})({
+ assertTrue: ${assertTrue},
+ notifyFinish: ${notifyFinish}
+ })`,
+ },
+ });
+
+ // Load a page in a content process, register the user script and then load a
+ // new page in the existing content process.
+ let url = `${BASE_URL}/file_sample.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+ await contentPage.loadURL(url);
+
+ // Run any additional test-specific assertions.
+ if (testFn) {
+ await testFn({ extension, contentPage, url });
+ }
+
+ await extension.awaitMessage("test_userScript_APIMethod:done");
+
+ await extension.unload();
+ await contentPage.close();
+}
+
+add_task(async function test_apiScript_exports_simple_sync_method() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ const scriptMetadata = script.metadata;
+
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod(
+ stringParam,
+ numberParam,
+ boolParam,
+ nullParam,
+ undefinedParam,
+ arrayParam
+ ) {
+ browser.test.assertEq(
+ "test-user-script-exported-apis",
+ scriptMetadata.name,
+ "Got the expected value for a string scriptMetadata property"
+ );
+ browser.test.assertEq(
+ null,
+ scriptMetadata.nullProperty,
+ "Got the expected value for a null scriptMetadata property"
+ );
+ browser.test.assertTrue(
+ scriptMetadata.arrayProperty &&
+ scriptMetadata.arrayProperty.length === 1 &&
+ scriptMetadata.arrayProperty[0] === "el1",
+ "Got the expected value for an array scriptMetadata property"
+ );
+ browser.test.assertTrue(
+ scriptMetadata.objectProperty &&
+ scriptMetadata.objectProperty.nestedProp === "nestedValue",
+ "Got the expected value for an object scriptMetadata property"
+ );
+
+ browser.test.assertEq(
+ "param1",
+ stringParam,
+ "Got the expected string parameter value"
+ );
+ browser.test.assertEq(
+ 123,
+ numberParam,
+ "Got the expected number parameter value"
+ );
+ browser.test.assertEq(
+ true,
+ boolParam,
+ "Got the expected boolean parameter value"
+ );
+ browser.test.assertEq(
+ null,
+ nullParam,
+ "Got the expected null parameter value"
+ );
+ browser.test.assertEq(
+ undefined,
+ undefinedParam,
+ "Got the expected undefined parameter value"
+ );
+
+ browser.test.assertEq(
+ 3,
+ arrayParam.length,
+ "Got the expected length on the array param"
+ );
+ browser.test.assertTrue(
+ arrayParam.includes(1),
+ "Got the expected result when calling arrayParam.includes"
+ );
+
+ return "returned_value";
+ },
+ });
+ });
+ }
+
+ function userScript() {
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ // Redefine the includes method on the Array prototype, to explicitly verify that the method
+ // redefined in the userScript is not used when accessing arrayParam.includes from the API script.
+ // eslint-disable-next-line no-extend-native
+ Array.prototype.includes = () => {
+ throw new Error("Unexpected prototype leakage");
+ };
+ const arrayParam = new Array(1, 2, 3); // eslint-disable-line no-array-constructor
+ const result = testAPIMethod(
+ "param1",
+ 123,
+ true,
+ null,
+ undefined,
+ arrayParam
+ );
+
+ assertTrue(
+ result === "returned_value",
+ `userScript got an unexpected result value: ${result}`
+ );
+
+ notifyFinish();
+ }
+
+ const userScriptMetadata = {
+ name: "test-user-script-exported-apis",
+ arrayProperty: ["el1"],
+ objectProperty: { nestedProp: "nestedValue" },
+ nullProperty: null,
+ };
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ userScriptMetadata,
+ });
+});
+
+add_task(async function test_apiScript_async_method() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod(param, cb, cb2, objWithCb) {
+ browser.test.assertEq(
+ "function",
+ typeof cb,
+ "Got a callback function parameter"
+ );
+ browser.test.assertTrue(
+ cb === cb2,
+ "Got the same cloned function for the same function parameter"
+ );
+
+ browser.runtime.sendMessage(param).then(bgPageRes => {
+ const cbResult = cb(script.export(bgPageRes));
+ browser.test.sendMessage("user-script-callback-return", cbResult);
+ });
+
+ return "resolved_value";
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ // Redefine Promise to verify that it doesn't break the WebExtensions internals
+ // that are going to use them.
+ const { Promise } = this;
+ Promise.resolve = function () {
+ throw new Error("Promise.resolve poisoning");
+ };
+ this.Promise = function () {
+ throw new Error("Promise constructor poisoning");
+ };
+
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ const cb = cbParam => {
+ return `callback param: ${JSON.stringify(cbParam)}`;
+ };
+ const cb2 = cb;
+ const asyncAPIResult = await testAPIMethod("param3", cb, cb2);
+
+ assertTrue(
+ asyncAPIResult === "resolved_value",
+ `userScript got an unexpected resolved value: ${asyncAPIResult}`
+ );
+
+ notifyFinish();
+ }
+
+ async function runtimeMessageListener(param) {
+ if (param !== "param3") {
+ browser.test.fail(`Got an unexpected message: ${param}`);
+ }
+
+ return { bgPageReply: true };
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ runtimeMessageListener,
+ async testFn({ extension }) {
+ const res = await extension.awaitMessage("user-script-callback-return");
+ equal(
+ res,
+ `callback param: ${JSON.stringify({ bgPageReply: true })}`,
+ "Got the expected userScript callback return value"
+ );
+ },
+ });
+});
+
+add_task(async function test_apiScript_method_with_webpage_objects_params() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod(windowParam, documentParam) {
+ browser.test.assertEq(
+ window,
+ windowParam,
+ "Got a reference to the native window as first param"
+ );
+ browser.test.assertEq(
+ window.document,
+ documentParam,
+ "Got a reference to the native document as second param"
+ );
+
+ // Return an uncloneable webpage object, which checks that if the returned object is from a principal
+ // that is subsumed by the userScript sandbox principal, it is returned without being cloned.
+ return windowParam;
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ const result = testAPIMethod(window, document);
+
+ // We expect the returned value to be the uncloneable window object.
+ assertTrue(
+ result === window,
+ `userScript got an unexpected returned value: ${result}`
+ );
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+});
+
+add_task(async function test_apiScript_method_got_param_with_methods() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ const scriptGlobal = script.global;
+ const ScriptFunction = scriptGlobal.Function;
+
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod(objWithMethods) {
+ browser.test.assertEq(
+ "objPropertyValue",
+ objWithMethods && objWithMethods.objProperty,
+ "Got the expected property on the object passed as a parameter"
+ );
+ browser.test.assertEq(
+ undefined,
+ objWithMethods?.objMethod,
+ "XrayWrapper should deny access to a callable property"
+ );
+
+ browser.test.assertTrue(
+ objWithMethods &&
+ objWithMethods.wrappedJSObject &&
+ objWithMethods.wrappedJSObject.objMethod instanceof
+ ScriptFunction.wrappedJSObject,
+ "The callable property is accessible on the wrappedJSObject"
+ );
+
+ browser.test.assertEq(
+ "objMethodResult: p1",
+ objWithMethods &&
+ objWithMethods.wrappedJSObject &&
+ objWithMethods.wrappedJSObject.objMethod("p1"),
+ "Got the expected result when calling the method on the wrappedJSObject"
+ );
+ return true;
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ let result = testAPIMethod({
+ objProperty: "objPropertyValue",
+ objMethod(param) {
+ return `objMethodResult: ${param}`;
+ },
+ });
+
+ assertTrue(
+ result === true,
+ `userScript got an unexpected returned value: ${result}`
+ );
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+});
+
+add_task(async function test_apiScript_method_throws_errors() {
+ function apiScript({ notifyFinish }) {
+ let proxyTrapsCount = 0;
+
+ browser.userScripts.onBeforeScript.addListener(script => {
+ const scriptGlobals = {
+ Error: script.global.Error,
+ TypeError: script.global.TypeError,
+ Proxy: script.global.Proxy,
+ };
+
+ script.defineGlobals({
+ notifyFinish,
+ testAPIMethod(errorTestName, returnRejectedPromise) {
+ let err;
+
+ switch (errorTestName) {
+ case "apiScriptError":
+ err = new Error(`${errorTestName} message`);
+ break;
+ case "apiScriptThrowsPlainString":
+ err = `${errorTestName} message`;
+ break;
+ case "apiScriptThrowsNull":
+ err = null;
+ break;
+ case "userScriptError":
+ err = new scriptGlobals.Error(`${errorTestName} message`);
+ break;
+ case "userScriptTypeError":
+ err = new scriptGlobals.TypeError(`${errorTestName} message`);
+ break;
+ case "userScriptProxyObject":
+ let proxyTarget = script.export({
+ name: "ProxyObject",
+ message: "ProxyObject message",
+ });
+ let proxyHandlers = script.export({
+ get(target, prop) {
+ proxyTrapsCount++;
+ switch (prop) {
+ case "name":
+ return "ProxyObjectGetName";
+ case "message":
+ return "ProxyObjectGetMessage";
+ }
+ return undefined;
+ },
+ getPrototypeOf() {
+ proxyTrapsCount++;
+ return scriptGlobals.TypeError;
+ },
+ });
+ err = new scriptGlobals.Proxy(proxyTarget, proxyHandlers);
+ break;
+ default:
+ browser.test.fail(`Unknown ${errorTestName} error testname`);
+ return undefined;
+ }
+
+ if (returnRejectedPromise) {
+ return Promise.reject(err);
+ }
+
+ throw err;
+ },
+ assertNoProxyTrapTriggered() {
+ browser.test.assertEq(
+ 0,
+ proxyTrapsCount,
+ "Proxy traps should not be triggered"
+ );
+ },
+ resetProxyTrapCounter() {
+ proxyTrapsCount = 0;
+ },
+ sendResults(results) {
+ browser.test.sendMessage("test-results", results);
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const {
+ assertNoProxyTrapTriggered,
+ notifyFinish,
+ resetProxyTrapCounter,
+ sendResults,
+ testAPIMethod,
+ } = this;
+
+ let apiThrowResults = {};
+ let apiThrowTestCases = [
+ "apiScriptError",
+ "apiScriptThrowsPlainString",
+ "apiScriptThrowsNull",
+ "userScriptError",
+ "userScriptTypeError",
+ "userScriptProxyObject",
+ ];
+ for (let errorTestName of apiThrowTestCases) {
+ try {
+ testAPIMethod(errorTestName);
+ } catch (err) {
+ // We expect that no proxy traps have been triggered by the WebExtensions internals.
+ if (errorTestName === "userScriptProxyObject") {
+ assertNoProxyTrapTriggered();
+ }
+
+ if (err instanceof Error) {
+ apiThrowResults[errorTestName] = {
+ name: err.name,
+ message: err.message,
+ };
+ } else {
+ apiThrowResults[errorTestName] = {
+ name: err && err.name,
+ message: err && err.message,
+ typeOf: typeof err,
+ value: err,
+ };
+ }
+ }
+ }
+
+ sendResults(apiThrowResults);
+
+ resetProxyTrapCounter();
+
+ let apiRejectsResults = {};
+ for (let errorTestName of apiThrowTestCases) {
+ try {
+ await testAPIMethod(errorTestName, true);
+ } catch (err) {
+ // We expect that no proxy traps have been triggered by the WebExtensions internals.
+ if (errorTestName === "userScriptProxyObject") {
+ assertNoProxyTrapTriggered();
+ }
+
+ if (err instanceof Error) {
+ apiRejectsResults[errorTestName] = {
+ name: err.name,
+ message: err.message,
+ };
+ } else {
+ apiRejectsResults[errorTestName] = {
+ name: err && err.name,
+ message: err && err.message,
+ typeOf: typeof err,
+ value: err,
+ };
+ }
+ }
+ }
+
+ sendResults(apiRejectsResults);
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ async testFn({ extension }) {
+ const expectedResults = {
+ // Any error not explicitly raised as a userScript objects or error instance is
+ // expected to be turned into a generic error message.
+ apiScriptError: {
+ name: "Error",
+ message: "An unexpected apiScript error occurred",
+ },
+
+ // When the api script throws a primitive value, we expect to receive it unmodified on
+ // the userScript side.
+ apiScriptThrowsPlainString: {
+ typeOf: "string",
+ value: "apiScriptThrowsPlainString message",
+ name: undefined,
+ message: undefined,
+ },
+ apiScriptThrowsNull: {
+ typeOf: "object",
+ value: null,
+ name: undefined,
+ message: undefined,
+ },
+
+ // Error messages that the apiScript has explicitly created as userScript's Error
+ // global instances are expected to be passing through unmodified.
+ userScriptError: { name: "Error", message: "userScriptError message" },
+ userScriptTypeError: {
+ name: "TypeError",
+ message: "userScriptTypeError message",
+ },
+
+ // Error raised from the apiScript as userScript proxy objects are expected to
+ // be passing through unmodified.
+ userScriptProxyObject: {
+ typeOf: "object",
+ name: "ProxyObjectGetName",
+ message: "ProxyObjectGetMessage",
+ },
+ };
+
+ info(
+ "Checking results from errors raised from an apiScript exported function"
+ );
+
+ const apiThrowResults = await extension.awaitMessage("test-results");
+
+ for (let [key, expected] of Object.entries(expectedResults)) {
+ Assert.deepEqual(
+ apiThrowResults[key],
+ expected,
+ `Got the expected error object for test case "${key}"`
+ );
+ }
+
+ Assert.deepEqual(
+ Object.keys(expectedResults).sort(),
+ Object.keys(apiThrowResults).sort(),
+ "the expected and actual test case names matches"
+ );
+
+ info(
+ "Checking expected results from errors raised from an apiScript exported function"
+ );
+
+ // Verify expected results from rejected promises returned from an apiScript exported function.
+ const apiThrowRejections = await extension.awaitMessage("test-results");
+
+ for (let [key, expected] of Object.entries(expectedResults)) {
+ Assert.deepEqual(
+ apiThrowRejections[key],
+ expected,
+ `Got the expected rejected object for test case "${key}"`
+ );
+ }
+
+ Assert.deepEqual(
+ Object.keys(expectedResults).sort(),
+ Object.keys(apiThrowRejections).sort(),
+ "the expected and actual test case names matches"
+ );
+ },
+ });
+});
+
+add_task(
+ async function test_apiScript_method_ensure_xraywrapped_proxy_in_params() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod(...args) {
+ // Proxies are opaque when wrapped in Xrays, and the proto of an opaque object
+ // is supposed to be Object.prototype.
+ browser.test.assertEq(
+ script.global.Object.prototype,
+ Object.getPrototypeOf(args[0]),
+ "Calling getPrototypeOf on the XrayWrapped proxy object doesn't run the proxy trap"
+ );
+
+ browser.test.assertTrue(
+ Array.isArray(args[0]),
+ "Got an array object for the XrayWrapped proxy object param"
+ );
+ browser.test.assertEq(
+ undefined,
+ args[0].length,
+ "XrayWrappers deny access to the length property"
+ );
+ browser.test.assertEq(
+ undefined,
+ args[0][0],
+ "Got the expected item in the array object"
+ );
+ return true;
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ let proxy = new Proxy(["expectedArrayValue"], {
+ getPrototypeOf() {
+ throw new Error("Proxy's getPrototypeOf trap");
+ },
+ get(target, prop, receiver) {
+ throw new Error("Proxy's get trap");
+ },
+ });
+
+ let result = testAPIMethod(proxy);
+
+ assertTrue(
+ result,
+ `userScript got an unexpected returned value: ${result}`
+ );
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+ }
+);
+
+add_task(async function test_apiScript_method_return_proxy_object() {
+ function apiScript(sharedTestAPIMethods) {
+ let proxyTrapsCount = 0;
+ let scriptTrapsCount = 0;
+
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethodError() {
+ return new Proxy(["expectedArrayValue"], {
+ getPrototypeOf(target) {
+ proxyTrapsCount++;
+ return Object.getPrototypeOf(target);
+ },
+ });
+ },
+ testAPIMethodOk() {
+ return new script.global.Proxy(
+ script.export(["expectedArrayValue"]),
+ script.export({
+ getPrototypeOf(target) {
+ scriptTrapsCount++;
+ return script.global.Object.getPrototypeOf(target);
+ },
+ })
+ );
+ },
+ assertNoProxyTrapTriggered() {
+ browser.test.assertEq(
+ 0,
+ proxyTrapsCount,
+ "Proxy traps should not be triggered"
+ );
+ },
+ assertScriptProxyTrapsCount(expected) {
+ browser.test.assertEq(
+ expected,
+ scriptTrapsCount,
+ "Script Proxy traps should have been triggered"
+ );
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const {
+ assertTrue,
+ assertNoProxyTrapTriggered,
+ assertScriptProxyTrapsCount,
+ notifyFinish,
+ testAPIMethodError,
+ testAPIMethodOk,
+ } = this;
+
+ let error;
+ try {
+ let result = testAPIMethodError();
+ notifyFinish(
+ `Unexpected returned value while expecting error: ${result}`
+ );
+ return;
+ } catch (err) {
+ error = err;
+ }
+
+ assertTrue(
+ error &&
+ error.message.includes("Return value not accessible to the userScript"),
+ `Got an unexpected error message: ${error}`
+ );
+
+ error = undefined;
+ try {
+ let result = testAPIMethodOk();
+ assertScriptProxyTrapsCount(0);
+ if (!(result instanceof Array)) {
+ notifyFinish(`Got an unexpected result: ${result}`);
+ return;
+ }
+ assertScriptProxyTrapsCount(1);
+ } catch (err) {
+ error = err;
+ }
+
+ assertTrue(!error, `Got an unexpected error: ${error}`);
+
+ assertNoProxyTrapTriggered();
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+});
+
+add_task(async function test_apiScript_returns_functions() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIReturnsFunction() {
+ // Return a function with provides the same kind of behavior
+ // of the API methods exported as globals.
+ return script.export(() => window);
+ },
+ testAPIReturnsObjWithMethod() {
+ return script.export({
+ getWindow() {
+ return window;
+ },
+ });
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const {
+ assertTrue,
+ notifyFinish,
+ testAPIReturnsFunction,
+ testAPIReturnsObjWithMethod,
+ } = this;
+
+ let resultFn = testAPIReturnsFunction();
+ assertTrue(
+ typeof resultFn === "function",
+ `userScript got an unexpected returned value: ${typeof resultFn}`
+ );
+
+ let fnRes = resultFn();
+ assertTrue(
+ fnRes === window,
+ `Got an unexpected value from the returned function: ${fnRes}`
+ );
+
+ let resultObj = testAPIReturnsObjWithMethod();
+ let actualTypeof = resultObj && typeof resultObj.getWindow;
+ assertTrue(
+ actualTypeof === "function",
+ `Returned object does not have the expected getWindow method: ${actualTypeof}`
+ );
+
+ let methodRes = resultObj.getWindow();
+ assertTrue(
+ methodRes === window,
+ `Got an unexpected value from the returned method: ${methodRes}`
+ );
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+});
+
+add_task(
+ async function test_apiScript_method_clone_non_subsumed_returned_values() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethodReturnOk() {
+ return script.export({
+ objKey1: {
+ nestedProp: "nestedvalue",
+ },
+ window,
+ });
+ },
+ testAPIMethodExplicitlyClonedError() {
+ let result = script.export({ apiScopeObject: undefined });
+
+ browser.test.assertThrows(
+ () => {
+ result.apiScopeObject = { disallowedProp: "disallowedValue" };
+ },
+ /Not allowed to define cross-origin object as property on .* XrayWrapper/,
+ "Assigning a property to a xRayWrapper is expected to throw"
+ );
+
+ // Let the exception to be raised, so that we check that the actual underlying
+ // error message is not leaking in the userScript (replaced by the generic
+ // "An unexpected apiScript error occurred" error message).
+ result.apiScopeObject = { disallowedProp: "disallowedValue" };
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const {
+ assertTrue,
+ notifyFinish,
+ testAPIMethodReturnOk,
+ testAPIMethodExplicitlyClonedError,
+ } = this;
+
+ let result = testAPIMethodReturnOk();
+
+ assertTrue(
+ result &&
+ "objKey1" in result &&
+ result.objKey1.nestedProp === "nestedvalue",
+ `userScript got an unexpected returned value: ${result}`
+ );
+
+ assertTrue(
+ result.window === window,
+ `userScript should have access to the window property: ${result.window}`
+ );
+
+ let error;
+ try {
+ result = testAPIMethodExplicitlyClonedError();
+ notifyFinish(
+ `Unexpected returned value while expecting error: ${result}`
+ );
+ return;
+ } catch (err) {
+ error = err;
+ }
+
+ // We expect the generic "unexpected apiScript error occurred" to be raised to the
+ // userScript code.
+ assertTrue(
+ error &&
+ error.message.includes("An unexpected apiScript error occurred"),
+ `Got an unexpected error message: ${error}`
+ );
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+ }
+);
+
+add_task(async function test_apiScript_method_export_primitive_types() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod(typeToExport) {
+ switch (typeToExport) {
+ case "boolean":
+ return script.export(true);
+ case "number":
+ return script.export(123);
+ case "string":
+ return script.export("a string");
+ case "symbol":
+ return script.export(Symbol("a symbol"));
+ }
+ return undefined;
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ let v = testAPIMethod("boolean");
+ assertTrue(v === true, `Should export a boolean`);
+
+ v = testAPIMethod("number");
+ assertTrue(v === 123, `Should export a number`);
+
+ v = testAPIMethod("string");
+ assertTrue(v === "a string", `Should export a string`);
+
+ v = testAPIMethod("symbol");
+ assertTrue(typeof v === "symbol", `Should export a symbol`);
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+});
+
+add_task(
+ async function test_apiScript_method_avoid_unnecessary_params_cloning() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethodReturnsParam(param) {
+ return param;
+ },
+ testAPIMethodReturnsUnwrappedParam(param) {
+ return param.wrappedJSObject;
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const {
+ assertTrue,
+ notifyFinish,
+ testAPIMethodReturnsParam,
+ testAPIMethodReturnsUnwrappedParam,
+ } = this;
+
+ let obj = {};
+
+ let result = testAPIMethodReturnsParam(obj);
+
+ assertTrue(
+ result === obj,
+ `Expect returned value to be strictly equal to the API method parameter`
+ );
+
+ result = testAPIMethodReturnsUnwrappedParam(obj);
+
+ assertTrue(
+ result === obj,
+ `Expect returned value to be strictly equal to the unwrapped API method parameter`
+ );
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+ }
+);
+
+add_task(async function test_apiScript_method_export_sparse_arrays() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod() {
+ const sparseArray = [];
+ sparseArray[3] = "third-element";
+ sparseArray[5] = "fifth-element";
+ return script.export(sparseArray);
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ const result = testAPIMethod(window, document);
+
+ // We expect the returned value to be the uncloneable window object.
+ assertTrue(
+ result && result.length === 6,
+ `the returned value should be an array of the expected length: ${result}`
+ );
+ assertTrue(
+ result[3] === "third-element",
+ `the third array element should have the expected value: ${result[3]}`
+ );
+ assertTrue(
+ result[5] === "fifth-element",
+ `the fifth array element should have the expected value: ${result[5]}`
+ );
+ assertTrue(
+ result[0] === undefined,
+ `the first array element should have the expected value: ${result[0]}`
+ );
+ assertTrue(!("0" in result), "Holey array should still be holey");
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_register.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_register.js
new file mode 100644
index 0000000000..fd57cd2736
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_register.js
@@ -0,0 +1,142 @@
+"use strict";
+
+const { createAppInfo } = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function test_userscripts_register_cookieStoreId() {
+ async function background() {
+ const matches = ["<all_urls>"];
+
+ await browser.test.assertRejects(
+ browser.userScripts.register({
+ js: [{ code: "" }],
+ matches,
+ cookieStoreId: "not_a_valid_cookieStoreId",
+ }),
+ /Invalid cookieStoreId/,
+ "userScript.register with an invalid cookieStoreId"
+ );
+
+ await browser.test.assertRejects(
+ browser.userScripts.register({
+ js: [{ code: "" }],
+ matches,
+ cookieStoreId: "",
+ }),
+ /Invalid cookieStoreId/,
+ "userScripts.register with an invalid cookieStoreId"
+ );
+
+ let cookieStoreIdJSArray = [
+ {
+ id: "firefox-container-1",
+ code: `document.body.textContent += "1"`,
+ },
+ {
+ id: ["firefox-container-2", "firefox-container-3"],
+ code: `document.body.textContent += "2-3"`,
+ },
+ {
+ id: "firefox-private",
+ code: `document.body.textContent += "private"`,
+ },
+ {
+ id: "firefox-default",
+ code: `document.body.textContent += "default"`,
+ },
+ ];
+
+ for (let { id, code } of cookieStoreIdJSArray) {
+ await browser.userScripts.register({
+ js: [{ code }],
+ matches,
+ runAt: "document_end",
+ cookieStoreId: id,
+ });
+ }
+
+ await browser.contentScripts.register({
+ js: [
+ {
+ code: `browser.test.sendMessage("last-content-script");`,
+ },
+ ],
+ matches,
+ runAt: "document_end",
+ });
+
+ browser.test.sendMessage("background_ready");
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["<all_urls>"],
+ user_scripts: {},
+ },
+ background,
+ incognitoOverride: "spanning",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background_ready");
+
+ registerCleanupFunction(() => extension.unload());
+
+ let testCases = [
+ {
+ contentPageOptions: { userContextId: 0 },
+ expectedTextContent: "default",
+ },
+ {
+ contentPageOptions: { userContextId: 1 },
+ expectedTextContent: "1",
+ },
+ {
+ contentPageOptions: { userContextId: 2 },
+ expectedTextContent: "2-3",
+ },
+ {
+ contentPageOptions: { userContextId: 3 },
+ expectedTextContent: "2-3",
+ },
+ {
+ contentPageOptions: { userContextId: 4 },
+ expectedTextContent: "",
+ },
+ {
+ contentPageOptions: { privateBrowsing: true },
+ expectedTextContent: "private",
+ },
+ ];
+
+ for (let test of testCases) {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`,
+ test.contentPageOptions
+ );
+
+ await extension.awaitMessage("last-content-script");
+
+ let result = await contentPage.spawn([], () => {
+ let textContent = content.document.body.textContent;
+ // Omit the default content from file_sample.html.
+ return textContent.replace("\n\nSample text\n\n\n\n", "");
+ });
+
+ await contentPage.close();
+
+ equal(
+ result,
+ test.expectedTextContent,
+ `Expected textContent on content page`
+ );
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_wasm.js b/toolkit/components/extensions/test/xpcshell/test_ext_wasm.js
new file mode 100644
index 0000000000..1a41361491
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_wasm.js
@@ -0,0 +1,135 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+// Common code snippet of background script in this test.
+function background() {
+ globalThis.onsecuritypolicyviolation = event => {
+ browser.test.assertEq("wasm-eval", event.blockedURI, "blockedURI");
+ if (browser.runtime.getManifest().version === 2) {
+ // In MV2, wasm eval violations are advisory only, as a transition tool.
+ browser.test.assertEq(event.disposition, "report", "MV2 disposition");
+ } else {
+ browser.test.assertEq(event.disposition, "enforce", "MV3 disposition");
+ }
+ browser.test.sendMessage("violated_csp", event.originalPolicy);
+ };
+ try {
+ let wasm = new WebAssembly.Module(
+ new Uint8Array([0, 0x61, 0x73, 0x6d, 0x1, 0, 0, 0])
+ );
+ browser.test.assertEq(wasm.toString(), "[object WebAssembly.Module]");
+ browser.test.sendMessage("result", "allowed");
+ } catch (e) {
+ browser.test.assertEq(
+ "call to WebAssembly.Module() blocked by CSP",
+ e.message,
+ "Expected error when blocked"
+ );
+ browser.test.sendMessage("result", "blocked");
+ }
+}
+
+add_task(async function test_wasm_v2() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ manifest_version: 2,
+ },
+ });
+
+ await extension.startup();
+ equal(await extension.awaitMessage("result"), "allowed");
+ await extension.unload();
+});
+
+add_task(async function test_wasm_v2_explicit() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ manifest_version: 2,
+ content_security_policy: `object-src; script-src 'self' 'wasm-unsafe-eval'`,
+ },
+ });
+
+ await extension.startup();
+ equal(await extension.awaitMessage("result"), "allowed");
+ await extension.unload();
+});
+
+// MV3 counterpart is test_wasm_v3_blocked_by_custom_csp.
+add_task(async function test_wasm_v2_blocked_in_report_only_mode() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ manifest_version: 2,
+ content_security_policy: `object-src; script-src 'self'`,
+ },
+ });
+
+ await extension.startup();
+ // "allowed" because wasm-unsafe-eval in MV2 is in report-only mode.
+ equal(await extension.awaitMessage("result"), "allowed");
+ equal(
+ await extension.awaitMessage("violated_csp"),
+ "object-src 'none'; script-src 'self'"
+ );
+ await extension.unload();
+});
+
+add_task(async function test_wasm_v3_blocked_by_default() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ manifest_version: 3,
+ },
+ });
+
+ await extension.startup();
+ equal(await extension.awaitMessage("result"), "blocked");
+ equal(
+ await extension.awaitMessage("violated_csp"),
+ "script-src 'self'; upgrade-insecure-requests",
+ "WASM usage violates default CSP in MV3"
+ );
+ await extension.unload();
+});
+
+// MV2 counterpart is test_wasm_v2_blocked_in_report_only_mode.
+add_task(async function test_wasm_v3_blocked_by_custom_csp() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: "object-src; script-src 'self'",
+ },
+ },
+ });
+
+ await extension.startup();
+ equal(await extension.awaitMessage("result"), "blocked");
+ equal(
+ await extension.awaitMessage("violated_csp"),
+ "object-src 'none'; script-src 'self'"
+ );
+ await extension.unload();
+});
+
+add_task(async function test_wasm_v3_allowed() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' 'wasm-unsafe-eval'; object-src 'self'`,
+ },
+ },
+ });
+
+ await extension.startup();
+ equal(await extension.awaitMessage("result"), "allowed");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js
new file mode 100644
index 0000000000..c616d162a5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js
@@ -0,0 +1,425 @@
+"use strict";
+
+const HOSTS = new Set(["example.com"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+const BASE_URL = "http://example.com";
+
+// Save seen realms for cache checking.
+let realms = new Set([]);
+
+server.registerPathHandler("/authenticate.sjs", (request, response) => {
+ let url = new URL(`${BASE_URL}${request.path}?${request.queryString}`);
+ let realm = url.searchParams.get("realm") || "mochitest";
+ let proxy_realm = url.searchParams.get("proxy_realm");
+
+ function checkAuthorization(authorization) {
+ let expected_user = url.searchParams.get("user");
+ if (!expected_user) {
+ return true;
+ }
+ let expected_pass = url.searchParams.get("pass");
+ let actual_user, actual_pass;
+ let authHeader = request.getHeader("Authorization");
+ let match = /Basic (.+)/.exec(authHeader);
+ if (match.length != 2) {
+ throw new Error("Couldn't parse auth header: " + authHeader);
+ }
+ let userpass = atob(match[1]); // no atob() :-(
+ match = /(.*):(.*)/.exec(userpass);
+ if (match.length != 3) {
+ throw new Error("Couldn't decode auth header: " + userpass);
+ }
+ actual_user = match[1];
+ actual_pass = match[2];
+ return expected_user === actual_user && expected_pass === actual_pass;
+ }
+
+ response.setHeader("Content-Type", "text/plain; charset=UTF-8", false);
+ if (proxy_realm && !request.hasHeader("Proxy-Authorization")) {
+ // We're not testing anything that requires checking the proxy auth user/password.
+ response.setStatusLine("1.0", 407, "Proxy authentication required");
+ response.setHeader(
+ "Proxy-Authenticate",
+ `basic realm="${proxy_realm}"`,
+ true
+ );
+ response.write("proxy auth required");
+ } else if (
+ !(
+ realms.has(realm) &&
+ request.hasHeader("Authorization") &&
+ checkAuthorization()
+ )
+ ) {
+ realms.add(realm);
+ response.setStatusLine(request.httpVersion, 401, "Authentication required");
+ response.setHeader("WWW-Authenticate", `basic realm="${realm}"`, true);
+ response.write("auth required");
+ } else {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok, got authorization");
+ }
+});
+
+function getExtension(bgConfig) {
+ function background(config) {
+ let path = config.path;
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.log(
+ `onBeforeRequest called with ${details.requestId} ${details.url}`
+ );
+ browser.test.sendMessage("onBeforeRequest");
+ return (
+ config.onBeforeRequest.hasOwnProperty("result") &&
+ config.onBeforeRequest.result
+ );
+ },
+ { urls: [path] },
+ config.onBeforeRequest.hasOwnProperty("extra")
+ ? config.onBeforeRequest.extra
+ : []
+ );
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ browser.test.log(
+ `onAuthRequired called with ${details.requestId} ${details.url}`
+ );
+ browser.test.assertEq(
+ config.realm,
+ details.realm,
+ "providing www authorization"
+ );
+ browser.test.sendMessage("onAuthRequired");
+ return (
+ config.onAuthRequired.hasOwnProperty("result") &&
+ config.onAuthRequired.result
+ );
+ },
+ { urls: [path] },
+ config.onAuthRequired.hasOwnProperty("extra")
+ ? config.onAuthRequired.extra
+ : []
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.log(
+ `onCompleted called with ${details.requestId} ${details.url}`
+ );
+ browser.test.sendMessage("onCompleted");
+ },
+ { urls: [path] }
+ );
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.log(
+ `onErrorOccurred called with ${JSON.stringify(details)}`
+ );
+ browser.test.sendMessage("onErrorOccurred");
+ },
+ { urls: [path] }
+ );
+ }
+
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", bgConfig.path],
+ },
+ background: `(${background})(${JSON.stringify(bgConfig)})`,
+ });
+}
+
+add_task(async function test_webRequest_auth() {
+ let config = {
+ path: `${BASE_URL}/*`,
+ realm: `webRequest_auth${Math.random()}`,
+ onBeforeRequest: {
+ extra: ["blocking"],
+ },
+ onAuthRequired: {
+ extra: ["blocking"],
+ result: {
+ authCredentials: {
+ username: "testuser",
+ password: "testpass",
+ },
+ },
+ },
+ };
+
+ let extension = getExtension(config);
+ await extension.startup();
+
+ let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+ await Promise.all([
+ extension.awaitMessage("onBeforeRequest"),
+ extension.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ extension.awaitMessage("onBeforeRequest"),
+ extension.awaitMessage("onCompleted"),
+ ]);
+ }),
+ ]);
+ await contentPage.close();
+
+ // Second time around to test cached credentials
+ contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+ await Promise.all([
+ extension.awaitMessage("onBeforeRequest"),
+ extension.awaitMessage("onCompleted"),
+ ]);
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_auth_cancelled() {
+ // Test that any auth listener can cancel.
+ let config = {
+ path: `${BASE_URL}/*`,
+ realm: `webRequest_auth${Math.random()}`,
+ onBeforeRequest: {
+ extra: ["blocking"],
+ },
+ onAuthRequired: {
+ extra: ["blocking"],
+ result: {
+ authCredentials: {
+ username: "testuser",
+ password: "testpass",
+ },
+ },
+ },
+ };
+
+ let ex1 = getExtension(config);
+ config.onAuthRequired.result = { cancel: true };
+ let ex2 = getExtension(config);
+ await ex1.startup();
+ await ex2.startup();
+
+ let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+ await Promise.all([
+ ex1.awaitMessage("onBeforeRequest"),
+ ex1.awaitMessage("onAuthRequired"),
+ ex1.awaitMessage("onErrorOccurred"),
+ ex2.awaitMessage("onBeforeRequest"),
+ ex2.awaitMessage("onAuthRequired"),
+ ex2.awaitMessage("onErrorOccurred"),
+ ]);
+
+ await contentPage.close();
+ await ex1.unload();
+ await ex2.unload();
+});
+
+add_task(async function test_webRequest_auth_nonblocking() {
+ let config = {
+ path: `${BASE_URL}/*`,
+ realm: `webRequest_auth${Math.random()}`,
+ onBeforeRequest: {
+ extra: ["blocking"],
+ },
+ onAuthRequired: {
+ extra: ["blocking"],
+ result: {
+ authCredentials: {
+ username: "testuser",
+ password: "testpass",
+ },
+ },
+ },
+ };
+
+ let ex1 = getExtension(config);
+ // non-blocking ext tries to cancel but cannot.
+ delete config.onBeforeRequest.extra;
+ delete config.onAuthRequired.extra;
+ config.onAuthRequired.result = { cancel: true };
+ let ex2 = getExtension(config);
+ await ex1.startup();
+ await ex2.startup();
+
+ let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+ await Promise.all([
+ ex1.awaitMessage("onBeforeRequest"),
+ ex1.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ ex1.awaitMessage("onBeforeRequest"),
+ ex1.awaitMessage("onCompleted"),
+ ]);
+ }),
+ ex2.awaitMessage("onBeforeRequest"),
+ ex2.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ ex2.awaitMessage("onBeforeRequest"),
+ ex2.awaitMessage("onCompleted"),
+ ]);
+ }),
+ ]);
+
+ await contentPage.close();
+ Services.obs.notifyObservers(null, "net:clear-active-logins");
+ await ex1.unload();
+ await ex2.unload();
+});
+
+add_task(async function test_webRequest_auth_blocking_noreturn() {
+ // The first listener is blocking but doesn't return anything. The second
+ // listener cancels the request.
+ let config = {
+ path: `${BASE_URL}/*`,
+ realm: `webRequest_auth${Math.random()}`,
+ onBeforeRequest: {
+ extra: ["blocking"],
+ },
+ onAuthRequired: {
+ extra: ["blocking"],
+ },
+ };
+
+ let ex1 = getExtension(config);
+ config.onAuthRequired.result = { cancel: true };
+ let ex2 = getExtension(config);
+ await ex1.startup();
+ await ex2.startup();
+
+ let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+ await Promise.all([
+ ex1.awaitMessage("onBeforeRequest"),
+ ex1.awaitMessage("onAuthRequired"),
+ ex1.awaitMessage("onErrorOccurred"),
+ ex2.awaitMessage("onBeforeRequest"),
+ ex2.awaitMessage("onAuthRequired"),
+ ex2.awaitMessage("onErrorOccurred"),
+ ]);
+
+ await contentPage.close();
+ await ex1.unload();
+ await ex2.unload();
+});
+
+add_task(async function test_webRequest_duelingAuth() {
+ let config = {
+ path: `${BASE_URL}/*`,
+ realm: `webRequest_auth${Math.random()}`,
+ onBeforeRequest: {
+ extra: ["blocking"],
+ },
+ onAuthRequired: {
+ extra: ["blocking"],
+ },
+ };
+ let exNone = getExtension(config);
+ await exNone.startup();
+
+ let authCredentials = {
+ username: `testuser_da1${Math.random()}`,
+ password: `testpass_da1${Math.random()}`,
+ };
+ config.onAuthRequired.result = { authCredentials };
+ let ex1 = getExtension(config);
+ await ex1.startup();
+
+ config.onAuthRequired.result = {};
+ let exEmpty = getExtension(config);
+ await exEmpty.startup();
+
+ config.onAuthRequired.result = {
+ authCredentials: {
+ username: `testuser_da2${Math.random()}`,
+ password: `testpass_da2${Math.random()}`,
+ },
+ };
+ let ex2 = getExtension(config);
+ await ex2.startup();
+
+ let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}&user=${authCredentials.username}&pass=${authCredentials.password}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+ await Promise.all([
+ exNone.awaitMessage("onBeforeRequest"),
+ exNone.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ exNone.awaitMessage("onBeforeRequest"),
+ exNone.awaitMessage("onCompleted"),
+ ]);
+ }),
+ exEmpty.awaitMessage("onBeforeRequest"),
+ exEmpty.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ exEmpty.awaitMessage("onBeforeRequest"),
+ exEmpty.awaitMessage("onCompleted"),
+ ]);
+ }),
+ ex1.awaitMessage("onBeforeRequest"),
+ ex1.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ ex1.awaitMessage("onBeforeRequest"),
+ ex1.awaitMessage("onCompleted"),
+ ]);
+ }),
+ ex2.awaitMessage("onBeforeRequest"),
+ ex2.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ ex2.awaitMessage("onBeforeRequest"),
+ ex2.awaitMessage("onCompleted"),
+ ]);
+ }),
+ ]);
+
+ await Promise.all([
+ await contentPage.close(),
+ exNone.unload(),
+ exEmpty.unload(),
+ ex1.unload(),
+ ex2.unload(),
+ ]);
+});
+
+add_task(async function test_webRequest_auth_proxy() {
+ function background(permissionPath) {
+ let proxyOk = false;
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ browser.test.log(
+ `handlingExt onAuthRequired called with ${details.requestId} ${details.url}`
+ );
+ if (details.isProxy) {
+ browser.test.succeed("providing proxy authorization");
+ proxyOk = true;
+ return { authCredentials: { username: "puser", password: "ppass" } };
+ }
+ browser.test.assertTrue(
+ proxyOk,
+ "providing www authorization after proxy auth"
+ );
+ browser.test.sendMessage("done");
+ return { authCredentials: { username: "auser", password: "apass" } };
+ },
+ { urls: [permissionPath] },
+ ["blocking"]
+ );
+ }
+
+ let handlingExt = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/*`],
+ },
+ background: `(${background})("${BASE_URL}/*")`,
+ });
+
+ await handlingExt.startup();
+
+ let requestUrl = `${BASE_URL}/authenticate.sjs?realm=webRequest_auth${Math.random()}&proxy_realm=proxy_auth${Math.random()}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+
+ await handlingExt.awaitMessage("done");
+ await contentPage.close();
+ await handlingExt.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js
new file mode 100644
index 0000000000..c18c75a580
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js
@@ -0,0 +1,311 @@
+"use strict";
+
+const BASE_URL = "http://example.com";
+const FETCH_ORIGIN = "http://example.com/data/file_sample.html";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+server.registerPathHandler("/status", (request, response) => {
+ let IfNoneMatch = request.hasHeader("If-None-Match")
+ ? request.getHeader("If-None-Match")
+ : "";
+
+ switch (IfNoneMatch) {
+ case "1234567890":
+ response.setStatusLine("1.1", 304, "Not Modified");
+ response.setHeader("Content-Type", "text/html", false);
+ response.setHeader("Etag", "1234567890", false);
+ break;
+ case "":
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.setHeader("Etag", "1234567890", false);
+ response.write("ok");
+ break;
+ default:
+ throw new Error(`Unexpected If-None-Match: ${IfNoneMatch}`);
+ }
+});
+
+// This test initialises a cache entry with a CSP header, then
+// loads the cached entry and replaces the CSP header with
+// a new one. We test in onResponseStarted that the header
+// is what we expect.
+add_task(async function test_replaceResponseHeaders() {
+ Services.prefs.setBoolPref("network.http.rcwn.enabled", false);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ function replaceHeader(headers, newHeader) {
+ headers = headers.filter(header => header.name !== newHeader.name);
+ headers.push(newHeader);
+ return headers;
+ }
+ let testHeaders = [
+ {
+ name: "Content-Security-Policy",
+ value: "object-src 'none'; script-src 'none'",
+ },
+ {
+ name: "Content-Security-Policy",
+ value: "object-src 'none'; script-src https:",
+ },
+ ];
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ if (!details.fromCache) {
+ // Add a CSP header on the initial request
+ details.responseHeaders.push(testHeaders[0]);
+ return {
+ responseHeaders: details.responseHeaders,
+ };
+ }
+ // Test that the header added during the initial request is
+ // now in the cached response.
+ let header = details.responseHeaders.filter(header => {
+ browser.test.log(`header ${header.name} = ${header.value}`);
+ return header.name == "Content-Security-Policy";
+ });
+ browser.test.assertEq(
+ header[0].value,
+ testHeaders[0].value,
+ "pre-cached header exists"
+ );
+ // Replace the cached value so we can test overriding the header that was cached.
+ return {
+ responseHeaders: replaceHeader(
+ details.responseHeaders,
+ testHeaders[1]
+ ),
+ };
+ },
+ {
+ urls: ["http://example.com/*/file_sample.html?r=*"],
+ },
+ ["blocking", "responseHeaders"]
+ );
+ browser.webRequest.onResponseStarted.addListener(
+ details => {
+ let needle = details.fromCache ? testHeaders[1] : testHeaders[0];
+ let header = details.responseHeaders.filter(header => {
+ browser.test.log(`header ${header.name} = ${header.value}`);
+ return header.name == needle.name && header.value == needle.value;
+ });
+ browser.test.assertEq(
+ header.length,
+ 1,
+ "header exists with correct value"
+ );
+ if (details.fromCache) {
+ browser.test.sendMessage("from-cache");
+ }
+ },
+ {
+ urls: ["http://example.com/*/file_sample.html?r=*"],
+ },
+ ["responseHeaders"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`;
+ await ExtensionTestUtils.fetch(FETCH_ORIGIN, url);
+ await ExtensionTestUtils.fetch(FETCH_ORIGIN, url);
+ await extension.awaitMessage("from-cache");
+
+ await extension.unload();
+});
+
+// This test initialises a cache entry with a CSP header, then
+// loads the cached entry and adds a second CSP header. We also
+// test that the browser has the CSP entries we expect.
+add_task(async function test_addCSPHeaders() {
+ Services.prefs.setBoolPref("network.http.rcwn.enabled", false);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ let testHeaders = [
+ {
+ name: "Content-Security-Policy",
+ value: "object-src 'none'; script-src 'none'",
+ },
+ {
+ name: "Content-Security-Policy",
+ value: "object-src 'none'; script-src https:",
+ },
+ ];
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ if (!details.fromCache) {
+ details.responseHeaders.push(testHeaders[0]);
+ return {
+ responseHeaders: details.responseHeaders,
+ };
+ }
+ browser.test.log("cached request received");
+ details.responseHeaders.push(testHeaders[1]);
+ return {
+ responseHeaders: details.responseHeaders,
+ };
+ },
+ {
+ urls: ["http://example.com/*/file_sample.html?r=*"],
+ },
+ ["blocking", "responseHeaders"]
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ let { name, value } = testHeaders[0];
+ if (details.fromCache) {
+ value = `${value}, ${testHeaders[1].value}`;
+ }
+ let header = details.responseHeaders.filter(header => {
+ browser.test.log(`header ${header.name} = ${header.value}`);
+ return header.name == name && header.value == value;
+ });
+ browser.test.assertEq(
+ header.length,
+ 1,
+ "header exists with correct value"
+ );
+ if (details.fromCache) {
+ browser.test.sendMessage("from-cache");
+ }
+ },
+ {
+ urls: ["http://example.com/*/file_sample.html?r=*"],
+ },
+ ["responseHeaders"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+ equal(contentPage.browser.csp.policyCount, 1, "expected 1 policy");
+ equal(
+ contentPage.browser.csp.getPolicy(0),
+ "object-src 'none'; script-src 'none'",
+ "expected policy"
+ );
+ await contentPage.close();
+
+ contentPage = await ExtensionTestUtils.loadContentPage(url);
+ equal(contentPage.browser.csp.policyCount, 2, "expected 2 policies");
+ equal(
+ contentPage.browser.csp.getPolicy(0),
+ "object-src 'none'; script-src 'none'",
+ "expected first policy"
+ );
+ equal(
+ contentPage.browser.csp.getPolicy(1),
+ "object-src 'none'; script-src https:",
+ "expected second policy"
+ );
+
+ await extension.awaitMessage("from-cache");
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+// This test verifies that a content type changed during
+// onHeadersReceived is cached. We initialize the cache,
+// then load against a url that will specifically return
+// a 304 status code.
+add_task(async function test_addContentTypeHeaders() {
+ Services.prefs.setBoolPref("network.http.rcwn.enabled", false);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ browser.test.log(`onBeforeSendHeaders ${JSON.stringify(details)}\n`);
+ },
+ {
+ urls: ["http://example.com/status*"],
+ },
+ ["blocking", "requestHeaders"]
+ );
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ browser.test.log(`onHeadersReceived ${JSON.stringify(details)}\n`);
+ if (!details.fromCache) {
+ browser.test.sendMessage("statusCode", details.statusCode);
+ const mime = details.responseHeaders.find(header => {
+ return header.value && header.name === "content-type";
+ });
+ if (mime) {
+ mime.value = "text/plain";
+ } else {
+ details.responseHeaders.push({
+ name: "content-type",
+ value: "text/plain",
+ });
+ }
+ return {
+ responseHeaders: details.responseHeaders,
+ };
+ }
+ },
+ {
+ urls: ["http://example.com/status*"],
+ },
+ ["blocking", "responseHeaders"]
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.log(`onCompleted ${JSON.stringify(details)}\n`);
+ const mime = details.responseHeaders.find(header => {
+ return header.value && header.name === "content-type";
+ });
+ browser.test.sendMessage("contentType", mime.value);
+ },
+ {
+ urls: ["http://example.com/status*"],
+ },
+ ["responseHeaders"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/status`
+ );
+ equal(await extension.awaitMessage("statusCode"), "200", "status OK");
+ equal(
+ await extension.awaitMessage("contentType"),
+ "text/plain",
+ "plain text header"
+ );
+ await contentPage.close();
+
+ contentPage = await ExtensionTestUtils.loadContentPage(`${BASE_URL}/status`);
+ equal(await extension.awaitMessage("statusCode"), "304", "not modified");
+ equal(
+ await extension.awaitMessage("contentType"),
+ "text/plain",
+ "plain text header"
+ );
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js
new file mode 100644
index 0000000000..a8405e5962
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js
@@ -0,0 +1,68 @@
+"use strict";
+
+const server = createHttpServer();
+const gServerUrl = `http://localhost:${server.identity.primaryPort}`;
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+add_task(async function test_cancel_with_reason() {
+ let ext = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "cancel@test" } },
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ return { cancel: true };
+ },
+ { urls: ["*://*/*"] },
+ ["blocking"]
+ );
+ },
+ });
+ await ext.startup();
+
+ let data = await new Promise(resolve => {
+ let ssm = Services.scriptSecurityManager;
+
+ let channel = NetUtil.newChannel({
+ uri: `${gServerUrl}/dummy`,
+ loadingPrincipal:
+ ssm.createContentPrincipalFromOrigin("http://localhost"),
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST,
+ securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ });
+
+ channel.asyncOpen({
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+
+ onStartRequest(request) {},
+
+ onStopRequest(request, statusCode) {
+ let properties = request.QueryInterface(Ci.nsIPropertyBag);
+ let id = properties.getProperty("cancelledByExtension");
+ let reason = request.loadInfo.requestBlockingReason;
+ resolve({ reason, id });
+ },
+
+ onDataAvailable() {},
+ });
+ });
+
+ Assert.equal(
+ Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST,
+ data.reason,
+ "extension cancelled request"
+ );
+ Assert.equal(
+ ext.id,
+ data.id,
+ "extension id attached to channel property bag"
+ );
+ await ext.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_containerIsolation.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_containerIsolation.js
new file mode 100644
index 0000000000..53a23fc149
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_containerIsolation.js
@@ -0,0 +1,59 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_userContextId_webRequest() {
+ Services.prefs.setBoolPref("extensions.userContextIsolation.enabled", true);
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "<all_urls>"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ async details => {
+ browser.test.assertEq(
+ "firefox-container-2",
+ details.cookieStoreId,
+ "cookieStoreId is set"
+ );
+ browser.test.notifyPass("allowed");
+ },
+ { urls: ["http://example.com/dummy"] }
+ );
+ },
+ });
+
+ Services.prefs.setCharPref(
+ "extensions.userContextIsolation.defaults.restricted",
+ "[1]"
+ );
+ await extension.startup();
+
+ let restrictedPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy",
+ { userContextId: 1 }
+ );
+
+ let allowedPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy",
+ {
+ userContextId: 2,
+ }
+ );
+ await extension.awaitFinish("allowed");
+
+ await extension.unload();
+ await restrictedPage.close();
+ await allowedPage.close();
+
+ Services.prefs.clearUserPref("extensions.userContextIsolation.enabled");
+ Services.prefs.clearUserPref(
+ "extensions.userContextIsolation.defaults.restricted"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js
new file mode 100644
index 0000000000..3485996f56
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js
@@ -0,0 +1,44 @@
+"use strict";
+
+// Test for Bug 1579911: Check that download requests created by the
+// downloads.download API can be observed by extensions.
+// The DNR version is in test_ext_dnr_download.js.
+add_task(async function testDownload() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "downloads",
+ "https://example.com/*",
+ ],
+ },
+ background: async function () {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("request_intercepted");
+ return { cancel: true };
+ },
+ {
+ urls: ["https://example.com/downloadtest"],
+ },
+ ["blocking"]
+ );
+
+ browser.downloads.onChanged.addListener(delta => {
+ browser.test.assertEq(delta.state.current, "interrupted");
+ browser.test.sendMessage("done");
+ });
+
+ await browser.downloads.download({
+ url: "https://example.com/downloadtest",
+ filename: "example.txt",
+ });
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("request_intercepted");
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_eventPage_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_eventPage_StreamFilter.js
new file mode 100644
index 0000000000..23c29aa155
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_eventPage_StreamFilter.js
@@ -0,0 +1,350 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+let clearLastPendingRequest;
+
+server.registerPathHandler("/pending_request", (request, response) => {
+ response.processAsync();
+ response.setHeader("Content-Length", "10000", false);
+ response.write("somedata\n");
+ let intervalID = setInterval(() => response.write("continue\n"), 50);
+
+ const clearPendingRequest = () => {
+ try {
+ clearInterval(intervalID);
+ response.finish();
+ } catch (e) {
+ // This will throw, but we don't care at this point.
+ }
+ };
+
+ clearLastPendingRequest = clearPendingRequest;
+ registerCleanupFunction(clearPendingRequest);
+});
+
+server.registerPathHandler("/completed_request", (request, response) => {
+ response.write("somedata\n");
+});
+
+add_setup(async () => {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+async function test_idletimeout_on_streamfilter({
+ manifest_version,
+ expectResetIdle,
+ expectStreamFilterStop,
+ requestUrlPath,
+}) {
+ const extension = ExtensionTestUtils.loadExtension({
+ background: `(${async function (urlPath) {
+ browser.webRequest.onBeforeRequest.addListener(
+ request => {
+ browser.test.log(`webRequest request intercepted: ${request.url}`);
+ const filter = browser.webRequest.filterResponseData(
+ request.requestId
+ );
+ const decoder = new TextDecoder("utf-8");
+ const encoder = new TextEncoder();
+ filter.onstart = () => {
+ browser.test.sendMessage("streamfilter:started");
+ };
+ filter.ondata = event => {
+ let str = decoder.decode(event.data, { stream: true });
+ filter.write(encoder.encode(str));
+ };
+ filter.onstop = () => {
+ filter.close();
+ browser.test.sendMessage("streamfilter:stopped");
+ };
+ },
+ {
+ urls: [`http://example.com/${urlPath}`],
+ },
+ ["blocking"]
+ );
+ browser.test.sendMessage("bg:ready");
+ }})("${requestUrlPath}")`,
+
+ useAddonManager: "temporary",
+ manifest: {
+ manifest_version,
+ background: manifest_version >= 3 ? {} : { persistent: false },
+ granted_host_permissions: manifest_version >= 3,
+ permissions:
+ manifest_version >= 3
+ ? ["webRequest", "webRequestBlocking", "webRequestFilterResponse"]
+ : ["webRequest", "webRequestBlocking"],
+ // host_permissions are merged with permissions on a MV2 test extension.
+ host_permissions: ["http://example.com/*"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bg:ready");
+ const { contextId } = extension.extension.backgroundContext;
+ notEqual(contextId, undefined, "Got a contextId for the background context");
+
+ info("Trigger a webRequest");
+ const testURL = `http://example.com/${requestUrlPath}`;
+ const promiseRequestCompleted = ExtensionTestUtils.fetch(
+ "http://example.com/",
+ testURL
+ ).catch(err => {
+ // This request is expected to be aborted when cleared after the test is exiting,
+ // otherwise rethrow the error to trigger an explicit failure.
+ if (/The operation was aborted/.test(err.message)) {
+ info(`Test webRequest fetching "${testURL}" aborted`);
+ } else {
+ ok(
+ false,
+ `Unexpected rejection triggered by the test webRequest fetching "${testURL}": ${err.message}`
+ );
+ throw err;
+ }
+ });
+
+ info("Wait for the stream filter to be started");
+ await extension.awaitMessage("streamfilter:started");
+
+ if (expectStreamFilterStop) {
+ await extension.awaitMessage("streamfilter:stopped");
+ }
+
+ info("Terminate the background script (simulated idle timeout)");
+
+ if (expectResetIdle) {
+ const promiseResetIdle = promiseExtensionEvent(
+ extension,
+ "background-script-reset-idle"
+ );
+
+ clearHistograms();
+ assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT);
+ assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID);
+
+ await extension.terminateBackground();
+ info("Wait for 'background-script-reset-idle' event to be emitted");
+ await promiseResetIdle;
+ equal(
+ extension.extension.backgroundContext.contextId,
+ contextId,
+ "Initial background context is still available as expected"
+ );
+
+ assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, {
+ category: "reset_streamfilter",
+ categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES,
+ });
+
+ assertHistogramCategoryNotEmpty(
+ WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID,
+ {
+ keyed: true,
+ key: extension.id,
+ category: "reset_streamfilter",
+ categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES,
+ }
+ );
+ } else {
+ const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+ );
+ const promiseProxyContextUnloaded = new Promise(resolve => {
+ function listener(evt, context) {
+ if (context.extension.id === extension.id) {
+ Management.off("proxy-context-unload", listener);
+ resolve();
+ }
+ }
+ Management.on("proxy-context-unload", listener);
+ });
+ await extension.terminateBackground();
+ await promiseProxyContextUnloaded;
+ equal(
+ extension.extension.backgroundContext,
+ undefined,
+ "Initial background context should have been terminated as expected"
+ );
+ }
+
+ await extension.unload();
+ clearLastPendingRequest();
+ await promiseRequestCompleted;
+}
+
+add_task(
+ {
+ pref_set: [["extensions.eventPages.enabled", true]],
+ },
+ async function test_idletimeout_on_active_streamfilter_mv2_eventpage() {
+ await test_idletimeout_on_streamfilter({
+ manifest_version: 2,
+ requestUrlPath: "pending_request",
+ expectStreamFilterStop: false,
+ expectResetIdle: true,
+ });
+ }
+);
+
+add_task(
+ {
+ pref_set: [["extensions.manifestV3.enabled", true]],
+ },
+ async function test_idletimeout_on_active_streamfilter_mv3() {
+ await test_idletimeout_on_streamfilter({
+ manifest_version: 3,
+ requestUrlPath: "pending_request",
+ expectStreamFilterStop: false,
+ expectResetIdle: true,
+ });
+ }
+);
+
+add_task(
+ {
+ pref_set: [["extensions.eventPages.enabled", true]],
+ },
+ async function test_idletimeout_on_inactive_streamfilter_mv2_eventpage() {
+ await test_idletimeout_on_streamfilter({
+ manifest_version: 2,
+ requestUrlPath: "completed_request",
+ expectStreamFilterStop: true,
+ expectResetIdle: false,
+ });
+ }
+);
+
+add_task(
+ {
+ pref_set: [["extensions.manifestV3.enabled", true]],
+ },
+ async function test_idletimeout_on_inactive_streamfilter_mv3() {
+ await test_idletimeout_on_streamfilter({
+ manifest_version: 3,
+ requestUrlPath: "completed_request",
+ expectStreamFilterStop: true,
+ expectResetIdle: false,
+ });
+ }
+);
+
+async function test_create_new_streamfilter_while_suspending({
+ manifest_version,
+}) {
+ const extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let interceptedRequestId;
+ let resolvePendingWebRequest;
+
+ browser.runtime.onSuspend.addListener(async () => {
+ await browser.test.assertThrows(
+ () => browser.webRequest.filterResponseData(interceptedRequestId),
+ /forbidden while background extension global is suspending/,
+ "Got the expected exception raised from filterResponseData calls while suspending"
+ );
+ browser.test.sendMessage("suspend-listener");
+ });
+
+ browser.runtime.onSuspendCanceled.addListener(async () => {
+ // Once onSuspendCanceled is emitted, filterResponseData
+ // is expected to don't throw.
+ const filter =
+ browser.webRequest.filterResponseData(interceptedRequestId);
+ resolvePendingWebRequest();
+ filter.onstop = () => {
+ filter.disconnect();
+ browser.test.sendMessage("suspend-canceled-listener");
+ };
+ });
+
+ browser.webRequest.onBeforeRequest.addListener(
+ request => {
+ browser.test.log(`webRequest request intercepted: ${request.url}`);
+ interceptedRequestId = request.requestId;
+ return new Promise(resolve => {
+ resolvePendingWebRequest = resolve;
+ browser.test.sendMessage("webrequest-listener:done");
+ });
+ },
+ {
+ urls: [`http://example.com/completed_request`],
+ },
+ ["blocking"]
+ );
+ browser.test.sendMessage("bg:ready");
+ },
+
+ useAddonManager: "temporary",
+ manifest: {
+ manifest_version,
+ background: manifest_version >= 3 ? {} : { persistent: false },
+ granted_host_permissions: manifest_version >= 3,
+ permissions:
+ manifest_version >= 3
+ ? ["webRequest", "webRequestBlocking", "webRequestFilterResponse"]
+ : ["webRequest", "webRequestBlocking"],
+ // host_permissions are merged with permissions on a MV2 test extension.
+ host_permissions: ["http://example.com/*"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bg:ready");
+ const { contextId } = extension.extension.backgroundContext;
+ notEqual(contextId, undefined, "Got a contextId for the background context");
+
+ info("Trigger a webRequest");
+ ExtensionTestUtils.fetch(
+ "http://example.com/",
+ `http://example.com/completed_request`
+ );
+
+ info("Wait for the web request to be intercepted and suspended");
+ await extension.awaitMessage("webrequest-listener:done");
+
+ info("Terminate the background script (simulated idle timeout)");
+
+ extension.terminateBackground({ disableResetIdleForTest: true });
+ await extension.awaitMessage("suspend-listener");
+
+ info("Simulated idle timeout canceled");
+ extension.extension.emit("background-script-reset-idle");
+ await extension.awaitMessage("suspend-canceled-listener");
+
+ await extension.unload();
+}
+
+add_task(
+ {
+ pref_set: [["extensions.eventPages.enabled", true]],
+ },
+ async function test_error_creating_new_streamfilter_while_suspending_mv2_eventpage() {
+ await test_create_new_streamfilter_while_suspending({
+ manifest_version: 2,
+ });
+ }
+);
+
+add_task(
+ {
+ pref_set: [["extensions.manifestV3.enabled", true]],
+ },
+ async function test_error_creating_new_streamfilter_while_suspending_mv3() {
+ await test_create_new_streamfilter_while_suspending({
+ manifest_version: 3,
+ });
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js
new file mode 100644
index 0000000000..0b826be08f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js
@@ -0,0 +1,607 @@
+"use strict";
+
+const HOSTS = new Set(["example.com", "example.org", "example.net"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+const FETCH_ORIGIN = "http://example.com/dummy";
+
+server.registerDirectory("/data/", do_get_file("data"));
+
+server.registerPathHandler("/redirect", (request, response) => {
+ let params = new URLSearchParams(request.queryString);
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ response.setHeader("Location", params.get("redirect_uri"));
+ response.setHeader("Access-Control-Allow-Origin", "*");
+});
+
+server.registerPathHandler("/redirect301", (request, response) => {
+ let params = new URLSearchParams(request.queryString);
+ response.setStatusLine(request.httpVersion, 301, "Moved Permanently");
+ response.setHeader("Location", params.get("redirect_uri"));
+ response.setHeader("Access-Control-Allow-Origin", "*");
+});
+
+server.registerPathHandler("/script302.js", (request, response) => {
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ response.setHeader("Location", "http://example.com/script.js");
+});
+
+server.registerPathHandler("/script.js", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/javascript");
+ response.write(String.raw`console.log("HELLO!");`);
+});
+
+server.registerPathHandler("/302.html", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html");
+ response.write(String.raw`
+ <script type="application/javascript" src="http://example.com/script302.js"></script>
+ `);
+});
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ response.write("ok");
+});
+
+server.registerPathHandler("/dummy.xhtml", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/xhtml+xml");
+ response.write(String.raw`<?xml version="1.0"?>
+ <html xml:lang="en" xmlns="http://www.w3.org/1999/xhtml">
+ <head/>
+ <body/>
+ </html>
+ `);
+});
+
+server.registerPathHandler("/lorem.html.gz", async (request, response) => {
+ response.processAsync();
+
+ response.setHeader(
+ "Content-Type",
+ "Content-Type: text/html; charset=utf-8",
+ false
+ );
+ response.setHeader("Content-Encoding", "gzip", false);
+
+ let data = await IOUtils.read(do_get_file("data/lorem.html.gz").path);
+ response.write(String.fromCharCode(...data));
+
+ response.finish();
+});
+
+// Test re-encoding the data stream for bug 1590898.
+add_task(async function test_stream_encoding_data() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ request => {
+ let filter = browser.webRequest.filterResponseData(request.requestId);
+ let decoder = new TextDecoder("utf-8");
+ let encoder = new TextEncoder();
+
+ filter.ondata = event => {
+ let str = decoder.decode(event.data, { stream: true });
+ filter.write(encoder.encode(str));
+ filter.disconnect();
+ };
+ },
+ {
+ urls: ["http://example.com/lorem.html.gz"],
+ types: ["main_frame"],
+ },
+ ["blocking"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/lorem.html.gz"
+ );
+
+ let content = await contentPage.spawn([], () => {
+ return this.content.document.body.textContent;
+ });
+
+ ok(
+ content.includes("Lorem ipsum dolor sit amet"),
+ `expected content received`
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+// Tests that the stream filter request is added to the document's load
+// group, and blocks an XML document's load event until after the filter
+// stops sending data.
+add_task(async function test_xml_document_loadgroup_blocking() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ request => {
+ let filter = browser.webRequest.filterResponseData(request.requestId);
+
+ let data = [];
+ filter.ondata = event => {
+ data.push(event.data);
+ };
+ filter.onstop = async () => {
+ browser.test.sendMessage("phase", "original-onstop");
+
+ // Make a few trips through the event loop.
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+
+ for (let buffer of data) {
+ filter.write(buffer);
+ }
+ browser.test.sendMessage("phase", "filter-onstop");
+ filter.close();
+ };
+ },
+ {
+ urls: ["http://example.com/dummy.xhtml"],
+ },
+ ["blocking"]
+ );
+ },
+
+ files: {
+ "content_script.js"() {
+ browser.test.sendMessage("phase", "content-script-start");
+ window.addEventListener(
+ "DOMContentLoaded",
+ () => {
+ browser.test.sendMessage("phase", "content-script-domload");
+ },
+ { once: true }
+ );
+ window.addEventListener(
+ "load",
+ () => {
+ browser.test.sendMessage("phase", "content-script-load");
+ },
+ { once: true }
+ );
+ },
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy.xhtml"],
+ run_at: "document_start",
+ js: ["content_script.js"],
+ },
+ ],
+ },
+ });
+
+ await extension.startup();
+
+ const EXPECTED = [
+ "original-onstop",
+ "filter-onstop",
+ "content-script-start",
+ "content-script-domload",
+ "content-script-load",
+ ];
+
+ let done = new Promise(resolve => {
+ let phases = [];
+ extension.onMessage("phase", phase => {
+ phases.push(phase);
+ if (phases.length === EXPECTED.length) {
+ resolve(phases);
+ }
+ });
+ });
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy.xhtml"
+ );
+
+ deepEqual(await done, EXPECTED, "Things happened, and in the right order");
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_filter_content_fetch() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ let pending = [];
+
+ browser.webRequest.onBeforeRequest.addListener(
+ data => {
+ let filter = browser.webRequest.filterResponseData(data.requestId);
+
+ let url = new URL(data.url);
+
+ if (url.searchParams.get("redirect_uri")) {
+ pending.push(
+ new Promise(resolve => {
+ filter.onerror = resolve;
+ }).then(() => {
+ browser.test.assertEq(
+ "Channel redirected",
+ filter.error,
+ "Got correct error for redirected filter"
+ );
+ })
+ );
+ }
+
+ filter.onstart = () => {
+ filter.write(new TextEncoder().encode(data.url));
+ };
+ filter.ondata = event => {
+ let str = new TextDecoder().decode(event.data);
+ browser.test.assertEq(
+ "ok",
+ str,
+ `Got unfiltered data for ${data.url}`
+ );
+ };
+ filter.onstop = () => {
+ filter.close();
+ };
+ },
+ {
+ urls: ["<all_urls>"],
+ },
+ ["blocking"]
+ );
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "done") {
+ await Promise.all(pending);
+ browser.test.notifyPass("stream-filter");
+ }
+ });
+ },
+
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "http://example.com/",
+ "http://example.org/",
+ ],
+ },
+ });
+
+ await extension.startup();
+
+ let results = [
+ ["http://example.com/dummy", "http://example.com/dummy"],
+ ["http://example.org/dummy", "http://example.org/dummy"],
+ ["http://example.net/dummy", "ok"],
+ [
+ "http://example.com/redirect?redirect_uri=http://example.com/dummy",
+ "http://example.com/dummy",
+ ],
+ [
+ "http://example.com/redirect?redirect_uri=http://example.org/dummy",
+ "http://example.org/dummy",
+ ],
+ ["http://example.com/redirect?redirect_uri=http://example.net/dummy", "ok"],
+ [
+ "http://example.net/redirect?redirect_uri=http://example.com/dummy",
+ "http://example.com/dummy",
+ ],
+ ].map(async ([url, expectedResponse]) => {
+ let text = await ExtensionTestUtils.fetch(FETCH_ORIGIN, url);
+ equal(text, expectedResponse, `Expected response for ${url}`);
+ });
+
+ await Promise.all(results);
+
+ extension.sendMessage("done");
+ await extension.awaitFinish("stream-filter");
+ await extension.unload();
+});
+
+add_task(async function test_filter_301() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onHeadersReceived.addListener(
+ data => {
+ if (data.statusCode !== 200) {
+ return;
+ }
+ let filter = browser.webRequest.filterResponseData(data.requestId);
+
+ filter.onstop = () => {
+ filter.close();
+ browser.test.notifyPass("stream-filter");
+ };
+ filter.onerror = () => {
+ browser.test.fail(`unexpected ${filter.error}`);
+ };
+ },
+ {
+ urls: ["<all_urls>"],
+ },
+ ["blocking"]
+ );
+ },
+
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "http://example.com/",
+ "http://example.org/",
+ ],
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/redirect301?redirect_uri=http://example.org/dummy"
+ );
+
+ await extension.awaitFinish("stream-filter");
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_filter_302() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ browser.test.sendMessage("filter-created");
+
+ filter.ondata = event => {
+ const script = "forceError();";
+ filter.write(new Uint8Array(new TextEncoder().encode(script)));
+ filter.close();
+ browser.test.sendMessage("filter-ondata");
+ };
+
+ filter.onerror = () => {
+ browser.test.assertEq(filter.error, "Channel redirected");
+ browser.test.sendMessage("filter-redirect");
+ };
+ },
+ {
+ urls: ["http://example.com/*.js"],
+ },
+ ["blocking"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/302.html"
+ );
+
+ await extension.awaitMessage("filter-created");
+ await extension.awaitMessage("filter-redirect");
+ await extension.awaitMessage("filter-created");
+ await extension.awaitMessage("filter-ondata");
+ await contentPage.close();
+ });
+ AddonTestUtils.checkMessages(messages, {
+ expected: [{ message: /forceError is not defined/ }],
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_alternate_cached_data() {
+ Services.prefs.setBoolPref("dom.script_loader.bytecode_cache.enabled", true);
+ Services.prefs.setIntPref("dom.script_loader.bytecode_cache.strategy", -1);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ let decoder = new TextDecoder("utf-8");
+ let encoder = new TextEncoder();
+
+ filter.ondata = event => {
+ let str = decoder.decode(event.data, { stream: true });
+ filter.write(encoder.encode(str));
+ filter.disconnect();
+ browser.test.assertTrue(
+ str.startsWith(`"use strict";`),
+ "ondata received decoded data"
+ );
+ browser.test.sendMessage("onBeforeRequest");
+ };
+
+ filter.onerror = () => {
+ // onBeforeRequest will always beat the cache race, so we should always
+ // get valid data in ondata.
+ browser.test.fail("error-received", filter.error);
+ };
+ },
+ {
+ urls: ["http://example.com/data/file_script_good.js"],
+ },
+ ["blocking"]
+ );
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ let decoder = new TextDecoder("utf-8");
+ let encoder = new TextEncoder();
+
+ // Because cache is always a race, intermittently we will succesfully
+ // beat the cache, in which case we pass in ondata. If cache wins,
+ // we pass in onerror.
+ // Running the test with --verify hits this cache race issue, as well
+ // it seems that the cache primarily looses on linux1804.
+ let gotone = false;
+ filter.ondata = event => {
+ browser.test.assertFalse(gotone, "cache lost the race");
+ gotone = true;
+ let str = decoder.decode(event.data, { stream: true });
+ filter.write(encoder.encode(str));
+ filter.disconnect();
+ browser.test.assertTrue(
+ str.startsWith(`"use strict";`),
+ "ondata received decoded data"
+ );
+ browser.test.sendMessage("onHeadersReceived");
+ };
+
+ filter.onerror = () => {
+ browser.test.assertFalse(gotone, "cache won the race");
+ gotone = true;
+ browser.test.assertEq(
+ filter.error,
+ "Channel is delivering cached alt-data"
+ );
+ browser.test.sendMessage("onHeadersReceived");
+ };
+ },
+ {
+ urls: ["http://example.com/data/file_script_bad.js"],
+ },
+ ["blocking"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/*"],
+ },
+ });
+
+ // Prime the cache so we have the script byte-cached.
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_script.html"
+ );
+ await contentPage.close();
+
+ await extension.startup();
+
+ let page_cached = await await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_script.html"
+ );
+ await Promise.all([
+ extension.awaitMessage("onBeforeRequest"),
+ extension.awaitMessage("onHeadersReceived"),
+ ]);
+ await page_cached.close();
+ await extension.unload();
+
+ Services.prefs.clearUserPref("dom.script_loader.bytecode_cache.enabled");
+ Services.prefs.clearUserPref("dom.script_loader.bytecode_cache.strategy");
+});
+
+add_task(async function test_webRequestFilterResponse_permission() {
+ function background() {
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ if (msg !== "testFilterResponseData") {
+ browser.test.fail(`Unexpected test message: ${msg}`);
+ return;
+ }
+
+ const [{ expectMissingPermissionError }] = args;
+
+ if (expectMissingPermissionError) {
+ browser.test.assertThrows(
+ () => browser.webRequest.filterResponseData("fake-response-id"),
+ /Missing required "webRequestFilterResponse" permission/,
+ "Expected missing webRequestFilterResponse permission error"
+ );
+ } else {
+ // Expect the generic error raised on invalid response id
+ // if the missing permission error isn't expected.
+ browser.test.assertTrue(
+ browser.webRequest.filterResponseData("fake-response-id"),
+ "Expected no missing webRequestFilterResponse permission error"
+ );
+ }
+
+ browser.test.notifyPass();
+ });
+ }
+
+ info(
+ "Verify MV2 extension does not require webRequestFilterResponse permission"
+ );
+ const extMV2 = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ manifest_version: 2,
+ permissions: ["webRequest", "webRequestBlocking"],
+ },
+ });
+
+ await extMV2.startup();
+ extMV2.sendMessage("testFilterResponseData", {
+ expectMissingPermissionError: false,
+ });
+ await extMV2.awaitFinish();
+ await extMV2.unload();
+
+ info(
+ "Verify filterResponseData throws on MV3 extension without webRequestFilterResponse permission"
+ );
+ const extMV3NoPerm = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ manifest_version: 3,
+ permissions: ["webRequest", "webRequestBlocking"],
+ },
+ });
+
+ await extMV3NoPerm.startup();
+ extMV3NoPerm.sendMessage("testFilterResponseData", {
+ expectMissingPermissionError: true,
+ });
+ await extMV3NoPerm.awaitFinish();
+ await extMV3NoPerm.unload();
+
+ info(
+ "Verify filterResponseData does not throw on MV3 extension without webRequestFilterResponse permission"
+ );
+ const extMV3WithPerm = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ manifest_version: 3,
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "webRequestFilterResponse",
+ ],
+ },
+ });
+
+ await extMV3WithPerm.startup();
+ extMV3WithPerm.sendMessage("testFilterResponseData", {
+ expectMissingPermissionError: false,
+ });
+ await extMV3WithPerm.awaitFinish();
+ await extMV3WithPerm.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js
new file mode 100644
index 0000000000..be5b5ec9bf
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js
@@ -0,0 +1,87 @@
+"use strict";
+
+AddonTestUtils.init(this);
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerPathHandler("/", (request, response) => {
+ response.setHeader("Content-Tpe", "text/plain", false);
+ response.write("OK");
+});
+
+add_task(async function test_all_webRequest_ResourceTypes() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "*://example.com/*"],
+ },
+ background() {
+ browser.test.onMessage.addListener(async msg => {
+ browser.webRequest[msg.event].addListener(
+ () => {},
+ { urls: ["*://example.com/*"], ...msg.filter },
+ ["blocking"]
+ );
+ // Call an API method implemented in the parent process to
+ // be sure that the webRequest listener has been registered
+ // in the parent process as well.
+ await browser.runtime.getBrowserInfo();
+ browser.test.sendMessage(`webRequest-listener-registered`);
+ });
+ },
+ });
+
+ await extension.startup();
+
+ const { Schemas } = ChromeUtils.importESModule(
+ "resource://gre/modules/Schemas.sys.mjs"
+ );
+ const webRequestSchema = Schemas.privilegedSchemaJSON
+ .get("chrome://extensions/content/schemas/web_request.json")
+ .deserialize({});
+ const ResourceType = webRequestSchema[1].types.filter(
+ type => type.id == "ResourceType"
+ )[0];
+ ok(
+ ResourceType && ResourceType.enum,
+ "Found ResourceType in the web_request.json schema"
+ );
+ info(
+ "Register webRequest.onBeforeRequest event listener for all supported ResourceType"
+ );
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ extension.sendMessage({
+ event: "onBeforeRequest",
+ filter: {
+ // Verify that the resourceType not supported is going to be ignored
+ // and all the ones supported does not trigger a ChannelWrapper.matches
+ // exception once the listener is being triggered.
+ types: [].concat(ResourceType.enum, "not-supported-resource-type"),
+ },
+ });
+ await extension.awaitMessage("webRequest-listener-registered");
+ ExtensionTestUtils.failOnSchemaWarnings();
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/dummy",
+ "http://example.com"
+ );
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ { message: /Warning processing types: .* "not-supported-resource-type"/ },
+ ],
+ forbidden: [{ message: /JavaScript Error: "ChannelWrapper.matches/ }],
+ });
+ info("No ChannelWrapper.matches errors have been logged");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js
new file mode 100644
index 0000000000..af0d8594f4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+AddonTestUtils.init(this);
+
+add_task(async function test_invalid_urls_in_webRequest_filter() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "https://example.com/*"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(() => {}, {
+ urls: ["htt:/example.com/*"],
+ types: ["main_frame"],
+ });
+ },
+ });
+ let { messages } = await promiseConsoleOutput(async () => {
+ await extension.startup();
+ await extension.unload();
+ });
+ AddonTestUtils.checkMessages(
+ messages,
+ {
+ expected: [
+ {
+ message: /ExtensionError: Invalid url pattern: htt:\/example.com\/*/,
+ },
+ ],
+ },
+ true
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js
new file mode 100644
index 0000000000..7a648d7e31
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js
@@ -0,0 +1,57 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerPathHandler("/HELLO", (req, res) => {
+ res.write("BYE");
+});
+
+add_task(async function request_from_extension_page() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://example.com/", "webRequest", "webRequestBlocking"],
+ },
+ files: {
+ "tab.html": `<!DOCTYPE html><script src="tab.js"></script>`,
+ "tab.js": async function () {
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ let { responseHeaders } = details;
+ responseHeaders.push({
+ name: "X-Added-by-Test",
+ value: "TheValue",
+ });
+ return { responseHeaders };
+ },
+ {
+ urls: ["http://example.com/HELLO"],
+ },
+ ["blocking", "responseHeaders"]
+ );
+
+ // Ensure that listener is registered (workaround for bug 1300234).
+ await browser.runtime.getPlatformInfo();
+
+ let response = await fetch("http://example.com/HELLO");
+ browser.test.assertEq(
+ "TheValue",
+ response.headers.get("X-added-by-test"),
+ "expected response header from webRequest listener"
+ );
+ browser.test.assertEq(
+ await response.text(),
+ "BYE",
+ "Expected response from server"
+ );
+ browser.test.sendMessage("done");
+ },
+ },
+ });
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/tab.html`,
+ { extension }
+ );
+ await extension.awaitMessage("done");
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js
new file mode 100644
index 0000000000..425d83560d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js
@@ -0,0 +1,99 @@
+"use strict";
+
+const HOSTS = new Set(["example.com", "example.org"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+const BASE_URL = "http://example.com";
+const FETCH_ORIGIN = "http://example.com/dummy";
+
+server.registerPathHandler("/return_headers.sjs", (request, response) => {
+ response.setHeader("Content-Type", "text/plain", false);
+
+ let headers = {};
+ for (let { data: header } of request.headers) {
+ headers[header] = request.getHeader(header);
+ }
+
+ response.write(JSON.stringify(headers));
+});
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+function getExtension(permission = "<all_urls>") {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", permission],
+ },
+ background() {
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ details.requestHeaders.push({ name: "Host", value: "example.org" });
+ return { requestHeaders: details.requestHeaders };
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking", "requestHeaders"]
+ );
+ },
+ });
+}
+
+add_task(async function test_host_header_accepted() {
+ let extension = getExtension();
+ await extension.startup();
+ let headers = JSON.parse(
+ await ExtensionTestUtils.fetch(
+ FETCH_ORIGIN,
+ `${BASE_URL}/return_headers.sjs`
+ )
+ );
+
+ equal(headers.host, "example.org", "Host header was set on request");
+
+ await extension.unload();
+});
+
+add_task(async function test_host_header_denied() {
+ let extension = getExtension(`${BASE_URL}/`);
+
+ await extension.startup();
+
+ let headers = JSON.parse(
+ await ExtensionTestUtils.fetch(
+ FETCH_ORIGIN,
+ `${BASE_URL}/return_headers.sjs`
+ )
+ );
+
+ equal(headers.host, "example.com", "Host header was not set on request");
+
+ await extension.unload();
+});
+
+add_task(async function test_host_header_restricted() {
+ Services.prefs.setCharPref(
+ "extensions.webextensions.restrictedDomains",
+ "example.org"
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.webextensions.restrictedDomains");
+ });
+
+ let extension = getExtension();
+
+ await extension.startup();
+
+ let headers = JSON.parse(
+ await ExtensionTestUtils.fetch(
+ FETCH_ORIGIN,
+ `${BASE_URL}/return_headers.sjs`
+ )
+ );
+
+ equal(headers.host, "example.com", "Host header was not set on request");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js
new file mode 100644
index 0000000000..fe3b6a8cf8
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js
@@ -0,0 +1,88 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_incognito_webrequest_access() {
+ let pb_extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ async details => {
+ browser.test.assertTrue(details.incognito, "incognito flag is set");
+ },
+ { urls: ["<all_urls>"], incognito: true },
+ ["blocking"]
+ );
+
+ browser.webRequest.onBeforeRequest.addListener(
+ async details => {
+ browser.test.assertFalse(
+ details.incognito,
+ "incognito flag is not set"
+ );
+ browser.test.notifyPass("webRequest.spanning");
+ },
+ { urls: ["<all_urls>"], incognito: false },
+ ["blocking"]
+ );
+ },
+ });
+
+ // Bug 1715801: Re-enable pbm portion on GeckoView
+ if (AppConstants.platform == "android") {
+ Services.prefs.setBoolPref("dom.security.https_first_pbm", false);
+ }
+
+ await pb_extension.startup();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ async details => {
+ browser.test.assertFalse(
+ details.incognito,
+ "incognito flag is not set"
+ );
+ browser.test.notifyPass("webRequest");
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+ },
+ });
+ // Load non-incognito extension to check that private requests are invisible to it.
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy",
+ { privateBrowsing: true }
+ );
+ await contentPage.close();
+
+ contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+ await extension.awaitFinish("webRequest");
+ await pb_extension.awaitFinish("webRequest.spanning");
+ await contentPage.close();
+
+ await pb_extension.unload();
+ await extension.unload();
+
+ // Bug 1715801: Re-enable pbm portion on GeckoView
+ if (AppConstants.platform == "android") {
+ Services.prefs.clearUserPref("dom.security.https_first_pbm");
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js
new file mode 100644
index 0000000000..402f54ca5e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js
@@ -0,0 +1,545 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+const server = createHttpServer({
+ hosts: ["example.net", "example.com"],
+});
+server.registerDirectory("/data/", do_get_file("data"));
+
+const pageContent = `<!DOCTYPE html>
+ <script id="script1" src="/data/file_script_good.js"></script>
+ <script id="script3" src="//example.com/data/file_script_bad.js"></script>
+ <img id="img1" src='/data/file_image_good.png'>
+ <img id="img3" src='//example.com/data/file_image_good.png'>
+`;
+
+server.registerPathHandler("/", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html");
+ if (request.queryString) {
+ response.setHeader(
+ "Content-Security-Policy",
+ decodeURIComponent(request.queryString)
+ );
+ }
+ response.write(pageContent);
+});
+
+let extensionData = {
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "*://example.net/*"],
+ },
+ background() {
+ let csp_value = undefined;
+ browser.test.onMessage.addListener(function (msg) {
+ csp_value = msg;
+ browser.test.sendMessage("csp-set");
+ });
+ browser.webRequest.onHeadersReceived.addListener(
+ e => {
+ browser.test.log(`onHeadersReceived ${e.requestId} ${e.url}`);
+ if (csp_value === undefined) {
+ browser.test.assertTrue(false, "extension called before CSP was set");
+ }
+ if (csp_value !== null) {
+ e.responseHeaders = e.responseHeaders.filter(
+ i => i.name.toLowerCase() != "content-security-policy"
+ );
+ if (csp_value !== "") {
+ e.responseHeaders.push({
+ name: "Content-Security-Policy",
+ value: csp_value,
+ });
+ }
+ }
+ return { responseHeaders: e.responseHeaders };
+ },
+ { urls: ["*://example.net/*"] },
+ ["blocking", "responseHeaders"]
+ );
+ },
+};
+
+/**
+ * @typedef {object} ExpectedResourcesToLoad
+ * @property {object} img1_loaded image from a first party origin.
+ * @property {object} img3_loaded image from a third party origin.
+ * @property {object} script1_loaded script from a first party origin.
+ * @property {object} script3_loaded script from a third party origin.
+ * @property {object} [cspJSON] expected final document CSP (in JSON format, See dom/webidl/CSPDictionaries.webidl).
+ */
+
+/**
+ * Test a combination of Content Security Policies against first/third party images/scripts.
+ *
+ * @param {object} opts
+ * @param {string} opts.site_csp The CSP to be sent by the site, or null.
+ * @param {string} opts.ext1_csp The CSP to be sent by the first extension,
+ * "" to remove the header, or null to not modify it.
+ * @param {string} opts.ext2_csp The CSP to be sent by the first extension,
+ * "" to remove the header, or null to not modify it.
+ * @param {ExpectedResourcesToLoad} opts.expect
+ * Object containing information which resources are expected to be loaded.
+ * @param {object} [opts.ext1_data] first test extension definition data (defaults to extensionData).
+ * @param {object} [opts.ext2_data] second test extension definition data (defaults to extensionData).
+ */
+async function test_csp({
+ site_csp,
+ ext1_csp,
+ ext2_csp,
+ expect,
+ ext1_data = extensionData,
+ ext2_data = extensionData,
+}) {
+ let extension1 = await ExtensionTestUtils.loadExtension(ext1_data);
+ let extension2 = await ExtensionTestUtils.loadExtension(ext2_data);
+ await extension1.startup();
+ await extension2.startup();
+ extension1.sendMessage(ext1_csp);
+ extension2.sendMessage(ext2_csp);
+ await extension1.awaitMessage("csp-set");
+ await extension2.awaitMessage("csp-set");
+
+ let csp_value = encodeURIComponent(site_csp || "");
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `http://example.net/?${csp_value}`
+ );
+ let results = await contentPage.spawn([], async () => {
+ let img1 = this.content.document.getElementById("img1");
+ let img3 = this.content.document.getElementById("img3");
+ let cspJSON = JSON.parse(this.content.document.cspJSON);
+ return {
+ img1_loaded: img1.complete && img1.naturalWidth > 0,
+ img3_loaded: img3.complete && img3.naturalWidth > 0,
+ // Note: "good" and "bad" are just placeholders; they don't mean anything.
+ script1_loaded: !!this.content.document.getElementById("good"),
+ script3_loaded: !!this.content.document.getElementById("bad"),
+ cspJSON,
+ };
+ });
+
+ await contentPage.close();
+ await extension1.unload();
+ await extension2.unload();
+
+ let action = {
+ true: "loaded",
+ false: "blocked",
+ };
+
+ info(
+ `test_csp: From "${site_csp}" to ${JSON.stringify(
+ ext1_csp
+ )} to ${JSON.stringify(ext2_csp)}`
+ );
+
+ equal(
+ expect.img1_loaded,
+ results.img1_loaded,
+ `expected first party image to be ${action[expect.img1_loaded]}`
+ );
+ equal(
+ expect.img3_loaded,
+ results.img3_loaded,
+ `expected third party image to be ${action[expect.img3_loaded]}`
+ );
+ equal(
+ expect.script1_loaded,
+ results.script1_loaded,
+ `expected first party script to be ${action[expect.script1_loaded]}`
+ );
+ equal(
+ expect.script3_loaded,
+ results.script3_loaded,
+ `expected third party script to be ${action[expect.script3_loaded]}`
+ );
+
+ if (expect.cspJSON) {
+ Assert.deepEqual(
+ expect.cspJSON,
+ results.cspJSON["csp-policies"],
+ `Got the expected final CSP set on the content document`
+ );
+ }
+}
+
+add_setup(async () => {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+// Test that merging csp header on both mv2 and mv3 extensions
+// (and combination of both).
+add_task(async function test_webRequest_mergecsp() {
+ const testCases = [
+ {
+ site_csp: "default-src *",
+ ext1_csp: "script-src 'none'",
+ ext2_csp: null,
+ expect: {
+ img1_loaded: true,
+ img3_loaded: true,
+ script1_loaded: false,
+ script3_loaded: false,
+ },
+ },
+ {
+ site_csp: null,
+ ext1_csp: "script-src 'none'",
+ ext2_csp: null,
+ expect: {
+ img1_loaded: true,
+ img3_loaded: true,
+ script1_loaded: false,
+ script3_loaded: false,
+ },
+ },
+ {
+ site_csp: "default-src *",
+ ext1_csp: "script-src 'none'",
+ ext2_csp: "img-src 'none'",
+ expect: {
+ img1_loaded: false,
+ img3_loaded: false,
+ script1_loaded: false,
+ script3_loaded: false,
+ },
+ },
+ {
+ site_csp: null,
+ ext1_csp: "script-src 'none'",
+ ext2_csp: "img-src 'none'",
+ expect: {
+ img1_loaded: false,
+ img3_loaded: false,
+ script1_loaded: false,
+ script3_loaded: false,
+ },
+ },
+ {
+ site_csp: "default-src *",
+ ext1_csp: "img-src example.com",
+ ext2_csp: "img-src example.org",
+ expect: {
+ img1_loaded: false,
+ img3_loaded: false,
+ script1_loaded: true,
+ script3_loaded: true,
+ },
+ },
+ ];
+
+ const extMV2Data = { ...extensionData };
+ const extMV3Data = {
+ ...extensionData,
+ useAddonManager: "temporary",
+ manifest: {
+ ...extensionData.manifest,
+ manifest_version: 3,
+ permissions: ["webRequest", "webRequestBlocking"],
+ host_permissions: ["*://example.net/*"],
+ granted_host_permissions: true,
+ },
+ };
+
+ info("Run all test cases on ext1 MV2 and ext2 MV2");
+ for (const testCase of testCases) {
+ await test_csp({
+ ...testCase,
+ ext1_data: extMV2Data,
+ ext2_data: extMV2Data,
+ });
+ }
+
+ info("Run all test cases on ext1 MV3 and ext2 MV3");
+ for (const testCase of testCases) {
+ await test_csp({
+ ...testCase,
+ ext1_data: extMV3Data,
+ ext2_data: extMV3Data,
+ });
+ }
+
+ info("Run all test cases on ext1 MV3 and ext2 MV2");
+ for (const testCase of testCases) {
+ await test_csp({
+ ...testCase,
+ ext1_data: extMV3Data,
+ ext2_data: extMV2Data,
+ });
+ }
+
+ info("Run all test cases on ext1 MV2 and ext2 MV3");
+ for (const testCase of testCases) {
+ await test_csp({
+ ...testCase,
+ ext1_data: extMV2Data,
+ ext2_data: extMV3Data,
+ });
+ }
+});
+
+add_task(async function test_remove_and_replace_csp_mv2() {
+ // CSP removed, CSP added.
+ await test_csp({
+ site_csp: "img-src 'self'",
+ ext1_csp: "",
+ ext2_csp: "img-src example.com",
+ expect: {
+ img1_loaded: false,
+ img3_loaded: true,
+ script1_loaded: true,
+ script3_loaded: true,
+ },
+ });
+
+ // CSP removed, CSP added.
+ await test_csp({
+ site_csp: "default-src 'none'",
+ ext1_csp: "",
+ ext2_csp: "img-src example.com",
+ expect: {
+ img1_loaded: false,
+ img3_loaded: true,
+ script1_loaded: true,
+ script3_loaded: true,
+ },
+ });
+
+ // CSP replaced - regression test for bug 1635781.
+ await test_csp({
+ site_csp: "default-src 'none'",
+ ext1_csp: "img-src example.com",
+ ext2_csp: null,
+ expect: {
+ img1_loaded: false,
+ img3_loaded: true,
+ script1_loaded: true,
+ script3_loaded: true,
+ },
+ });
+
+ // CSP unchanged, CSP replaced - regression test for bug 1635781.
+ await test_csp({
+ site_csp: "default-src 'none'",
+ ext1_csp: null,
+ ext2_csp: "img-src example.com",
+ expect: {
+ img1_loaded: false,
+ img3_loaded: true,
+ script1_loaded: true,
+ script3_loaded: true,
+ },
+ });
+
+ // CSP replaced, CSP removed.
+ await test_csp({
+ site_csp: "default-src 'none'",
+ ext1_csp: "img-src example.com",
+ ext2_csp: "",
+ expect: {
+ img1_loaded: true,
+ img3_loaded: true,
+ script1_loaded: true,
+ script3_loaded: true,
+ },
+ });
+});
+
+// Test that fully replace the website csp header from an mv3 extension
+// isn't allowed and it is considered a no-op.
+add_task(async function test_remove_and_replace_csp_mv3() {
+ const extMV2Data = { ...extensionData };
+
+ const extMV3Data = {
+ ...extensionData,
+ useAddonManager: "temporary",
+ manifest: {
+ ...extensionData.manifest,
+ manifest_version: 3,
+ permissions: ["webRequest", "webRequestBlocking"],
+ host_permissions: ["*://example.net/*"],
+ granted_host_permissions: true,
+ },
+ };
+
+ await test_csp({
+ // site: CSP strict on images, lax on default and script src.
+ site_csp: "img-src 'self'",
+ // ext1: MV3 extension which return an empty CSP header (which is a no-op).
+ ext1_csp: "",
+ // ext2: MV3 extension which return a CSP header (which is expected to be merged).
+ ext2_csp: "img-src example.com",
+ expect: {
+ img1_loaded: false,
+ img3_loaded: false,
+ script1_loaded: true,
+ script3_loaded: true,
+ cspJSON: [
+ { "img-src": ["'self'"], "report-only": false },
+ { "img-src": ["http://example.com"], "report-only": false },
+ ],
+ },
+ ext1_data: extMV3Data,
+ ext2_data: extMV3Data,
+ });
+
+ await test_csp({
+ // site: CSP strict on default-src.
+ site_csp: "default-src 'none'",
+ // ext1: MV3 extension which return an empty CSP header (which is a no-op).
+ ext1_csp: "",
+ // ext2: MV3 extension which return a CSP header (which is expected to be merged).
+ ext2_csp: "img-src example.com",
+ expect: {
+ img1_loaded: false,
+ img3_loaded: false,
+ script1_loaded: false,
+ script3_loaded: false,
+ cspJSON: [
+ { "default-src": ["'none'"], "report-only": false },
+ { "img-src": ["http://example.com"], "report-only": false },
+ ],
+ },
+ ext1_data: extMV3Data,
+ ext2_data: extMV3Data,
+ });
+
+ await test_csp({
+ // site: CSP strict on default-src.
+ site_csp: "default-src 'none'",
+ // ext1: MV3 extension which return a CSP header (which is expected to be merged and to
+ // not be able to make it less strict).
+ ext1_csp: "img-src example.com",
+ // ext2: MV3 extension which leaves the header unmodified.
+ ext2_csp: null,
+ expect: {
+ img1_loaded: false,
+ img3_loaded: false,
+ script1_loaded: false,
+ script3_loaded: false,
+ cspJSON: [
+ { "default-src": ["'none'"], "report-only": false },
+ { "img-src": ["http://example.com"], "report-only": false },
+ ],
+ },
+ ext1_data: extMV3Data,
+ ext2_data: extMV3Data,
+ });
+
+ await test_csp({
+ // site: CSP strict on default-src.
+ site_csp: "default-src 'none'",
+ // ext1: MV3 extension which merges additional directive into the site csp (and can't make
+ // it less strict).
+ ext1_csp: "img-src example.com",
+ // ext2: MV3 extension which merges an empty CSP header (which is a no-op, unlike with MV2).
+ ext2_csp: "",
+ expect: {
+ img1_loaded: false,
+ img3_loaded: false,
+ script1_loaded: false,
+ script3_loaded: false,
+ cspJSON: [
+ { "default-src": ["'none'"], "report-only": false },
+ { "img-src": ["http://example.com"], "report-only": false },
+ ],
+ },
+ ext1_data: extMV3Data,
+ ext2_data: extMV3Data,
+ });
+
+ await test_csp({
+ // site: lax CSP (which is expected to be made stricted by the ext1 extension).
+ site_csp: "default-src *",
+ // ext1: MV3 extension which wants to set a stricter CSP (expected to work fine with the MV3 extension)
+ ext1_csp: "default-src 'none'",
+ // ext2: MV3 extension which leaves it unchanged.
+ ext2_csp: null,
+ expect: {
+ img1_loaded: false,
+ img3_loaded: false,
+ script1_loaded: false,
+ script3_loaded: false,
+ cspJSON: [
+ { "default-src": ["*"], "report-only": false },
+ { "default-src": ["'none'"], "report-only": false },
+ ],
+ },
+ ext1_data: extMV3Data,
+ ext2_data: extMV3Data,
+ });
+
+ await test_csp({
+ // site: CSP strict on default-src.
+ site_csp: "default-src 'none'",
+ // ext1: MV3 extension and tries to replace the strict site csp with this lax one
+ // (but as an MV3 extension that is going to be merged to the site csp and the
+ // resulting site CSP is expected to stay strict).
+ ext1_csp: "default-src *",
+ // ext2: MV3 extension which leaves it unchanged.
+ ext2_csp: null,
+ expect: {
+ // strict site csp merged with the lax one from ext1 stays strict.
+ img1_loaded: false,
+ img3_loaded: false,
+ script1_loaded: false,
+ script3_loaded: false,
+ cspJSON: [
+ { "default-src": ["'none'"], "report-only": false },
+ { "default-src": ["*"], "report-only": false },
+ ],
+ },
+ ext1_data: extMV3Data,
+ ext2_data: extMV3Data,
+ });
+
+ await test_csp({
+ // site: CSP strict on default-src.
+ site_csp: "default-src 'none'",
+ // ext1: MV3 extension which return an empty CSP (expected to be a no-op for an MV3 extension).
+ ext1_csp: "",
+ // ext2: MV2 exension which wants to replace the site csp with a lax one (and still be allowed to
+ // because the empty one from the MV3 extension is expected to be a no-op).
+ ext2_csp: "default-src *",
+ expect: {
+ img1_loaded: true,
+ img3_loaded: true,
+ script1_loaded: true,
+ script3_loaded: true,
+ cspJSON: [{ "default-src": ["*"], "report-only": false }],
+ },
+ ext1_data: extMV3Data,
+ ext2_data: extMV2Data,
+ });
+
+ await test_csp({
+ // site: CSP strict on default-src.
+ site_csp: "default-src 'none'",
+ // ext1: MV3 extension which return an empty CSP (which is expected to be a no-op).
+ ext1_csp: "",
+ // ext2: MV2 extension which also returns an empty CSP (which for an MV2 extension is expected
+ // to clear the CSP).
+ ext2_csp: "",
+ expect: {
+ img1_loaded: true,
+ img3_loaded: true,
+ script1_loaded: true,
+ script3_loaded: true,
+ // Expect the resulting final document CSP to be empty (due to the MV2 extension clearing it).
+ cspJSON: [],
+ },
+ ext1_data: extMV3Data,
+ ext2_data: extMV2Data,
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js
new file mode 100644
index 0000000000..bfb4b55856
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js
@@ -0,0 +1,153 @@
+"use strict";
+
+const HOSTS = new Set(["example.com", "example.org"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+const BASE_URL = "http://example.com";
+
+server.registerDirectory("/data/", do_get_file("data"));
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+function sendMessage(page, msg, data) {
+ return MessageChannel.sendMessage(page.browser.messageManager, msg, data);
+}
+
+add_task(async function test_permissions() {
+ function background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ if (details.url.includes("_original")) {
+ let redirectUrl = details.url
+ .replace("example.org", "example.com")
+ .replace("_original", "_redirected");
+ return { redirectUrl };
+ }
+ return {};
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ const frameScript = () => {
+ const messageListener = {
+ async receiveMessage({ target, messageName, recipient, data, name }) {
+ /* globals content */
+ let doc = content.document;
+ let iframe = doc.createElement("iframe");
+ doc.body.appendChild(iframe);
+
+ let promise = new Promise(resolve => {
+ let listener = event => {
+ content.removeEventListener("message", listener);
+ resolve(event.data);
+ };
+ content.addEventListener("message", listener);
+ });
+
+ iframe.setAttribute(
+ "src",
+ "http://example.com/data/file_WebRequest_permission_original.html"
+ );
+ let result = await promise;
+ doc.body.removeChild(iframe);
+ return result;
+ },
+ };
+
+ const { MessageChannel } = ChromeUtils.importESModule(
+ "resource://testing-common/MessageChannel.sys.mjs"
+ );
+ MessageChannel.addListener(this, "Test:Check", messageListener);
+ };
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/dummy`
+ );
+ await contentPage.loadFrameScript(frameScript);
+
+ let results = await sendMessage(contentPage, "Test:Check", {});
+ equal(
+ results.page,
+ "redirected",
+ "Regular webRequest redirect works on an unprivileged page"
+ );
+ equal(
+ results.script,
+ "redirected",
+ "Regular webRequest redirect works from an unprivileged page"
+ );
+
+ Services.prefs.setBoolPref("extensions.webapi.testing", true);
+ Services.prefs.setBoolPref("extensions.webapi.testing.http", true);
+
+ results = await sendMessage(contentPage, "Test:Check", {});
+ equal(
+ results.page,
+ "original",
+ "webRequest redirect fails on a privileged page"
+ );
+ equal(
+ results.script,
+ "original",
+ "webRequest redirect fails from a privileged page"
+ );
+
+ await extension.unload();
+ await contentPage.close();
+});
+
+add_task(async function test_no_webRequestBlocking_error() {
+ function background() {
+ const expectedError =
+ "Using webRequest.addListener with the blocking option " +
+ "requires the 'webRequestBlocking' permission.";
+
+ const blockingEvents = [
+ "onBeforeRequest",
+ "onBeforeSendHeaders",
+ "onHeadersReceived",
+ "onAuthRequired",
+ ];
+
+ for (let eventName of blockingEvents) {
+ browser.test.assertThrows(
+ () => {
+ browser.webRequest[eventName].addListener(
+ details => {},
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+ },
+ expectedError,
+ `Got the expected exception for a blocking webRequest.${eventName} listener`
+ );
+ }
+ }
+
+ const extensionData = {
+ manifest: { permissions: ["webRequest", "<all_urls>"] },
+ background,
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirectProperty.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirectProperty.js
new file mode 100644
index 0000000000..5a448abb2a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirectProperty.js
@@ -0,0 +1,64 @@
+"use strict";
+
+const server = createHttpServer();
+const gServerUrl = `http://localhost:${server.identity.primaryPort}`;
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+add_task(async function test_redirect_property() {
+ function background(serverUrl) {
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ return { redirectUrl: `${serverUrl}/dummy` };
+ },
+ { urls: ["*://localhost/*"] },
+ ["blocking"]
+ );
+ }
+
+ let ext = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "redirect@test" } },
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background: `(${background})("${gServerUrl}")`,
+ });
+ await ext.startup();
+
+ let data = await new Promise(resolve => {
+ let ssm = Services.scriptSecurityManager;
+
+ let channel = NetUtil.newChannel({
+ uri: `${gServerUrl}/redirect`,
+ loadingPrincipal:
+ ssm.createContentPrincipalFromOrigin("http://localhost"),
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST,
+ securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ });
+
+ channel.asyncOpen({
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+
+ onStartRequest(request) {},
+
+ onStopRequest(request, statusCode) {
+ let properties = request.QueryInterface(Ci.nsIPropertyBag);
+ let id = properties.getProperty("redirectedByExtension");
+ resolve({ id, url: request.QueryInterface(Ci.nsIChannel).URI.spec });
+ },
+
+ onDataAvailable() {},
+ });
+ });
+
+ Assert.equal(`${gServerUrl}/dummy`, data.url, "request redirected");
+ Assert.equal(
+ ext.id,
+ data.id,
+ "extension id attached to channel property bag"
+ );
+ await ext.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js
new file mode 100644
index 0000000000..8153a596a3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js
@@ -0,0 +1,129 @@
+"use strict";
+
+// StreamFilters should be closed upon a redirect.
+//
+// Some redirects are already tested in other tests:
+// - test_ext_webRequest_filterResponseData.js tests fetch requests.
+// - test_ext_webRequest_viewsource_StreamFilter.js tests view-source documents.
+//
+// Usually, redirects are caught in StreamFilterParent::OnStartRequest, but due
+// to the fact that AttachStreamFilter is deferred for document requests, OSR is
+// not called and the cleanup is triggered from nsHttpChannel::ReleaseListeners.
+
+const server = createHttpServer({ hosts: ["example.com", "example.org"] });
+
+server.registerPathHandler("/redir", (request, response) => {
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ response.setHeader("Location", "/target");
+});
+server.registerPathHandler("/target", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+server.registerPathHandler("/RedirectToRedir.html", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
+ response.write("<script>location.href='http://example.com/redir';</script>");
+});
+server.registerPathHandler("/iframeWithRedir.html", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
+ response.write("<iframe src='http://example.com/redir'></iframe>");
+});
+
+function loadRedirectCatcherExtension() {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "*://*/*"],
+ },
+ background() {
+ const closeCounts = {};
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ let expectedError = "Channel redirected";
+ if (details.type === "main_frame" || details.type === "sub_frame") {
+ // Message differs for the reason stated at the top of this file.
+ // TODO bug 1683862: Make error message more accurate.
+ expectedError = "Invalid request ID";
+ }
+
+ closeCounts[details.requestId] = 0;
+
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ filter.onstart = () => {
+ filter.disconnect();
+ browser.test.fail("Unexpected filter.onstart");
+ };
+ filter.onerror = function () {
+ closeCounts[details.requestId]++;
+ browser.test.assertEq(expectedError, filter.error, "filter.error");
+ };
+ },
+ { urls: ["*://*/redir"] },
+ ["blocking"]
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ // filter.onerror from the redirect request should be called before
+ // webRequest.onCompleted of the redirection target. Regression test
+ // for bug 1683189.
+ browser.test.assertEq(
+ 1,
+ closeCounts[details.requestId],
+ "filter from initial, redirected request should have been closed"
+ );
+ browser.test.log("Intentionally canceling view-source request");
+ browser.test.sendMessage("req_end", details.type);
+ },
+ { urls: ["*://*/target"] }
+ );
+ },
+ });
+}
+
+add_task(async function redirect_document() {
+ let extension = loadRedirectCatcherExtension();
+ await extension.startup();
+
+ {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/redir"
+ );
+ equal(await extension.awaitMessage("req_end"), "main_frame", "is top doc");
+ await contentPage.close();
+ }
+
+ {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/iframeWithRedir.html"
+ );
+ equal(await extension.awaitMessage("req_end"), "sub_frame", "is sub doc");
+ await contentPage.close();
+ }
+
+ await extension.unload();
+});
+
+// Cross-origin redirect = process switch.
+add_task(async function redirect_document_cross_origin() {
+ let extension = loadRedirectCatcherExtension();
+ await extension.startup();
+
+ {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.org/RedirectToRedir.html"
+ );
+ equal(await extension.awaitMessage("req_end"), "main_frame", "is top doc");
+ await contentPage.close();
+ }
+
+ {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.org/iframeWithRedir.html"
+ );
+ equal(await extension.awaitMessage("req_end"), "sub_frame", "is sub doc");
+ await contentPage.close();
+ }
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js
new file mode 100644
index 0000000000..e390e3348e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js
@@ -0,0 +1,47 @@
+"use strict";
+
+// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1573456
+add_task(async function test_mozextension_page_loaded_in_extension_process() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "https://example.com/*",
+ ],
+ web_accessible_resources: ["test.html"],
+ },
+ files: {
+ "test.html": '<!DOCTYPE html><script src="test.js"></script>',
+ "test.js": () => {
+ browser.test.assertTrue(
+ browser.webRequest,
+ "webRequest API should be available"
+ );
+
+ browser.test.sendMessage("test_done");
+ },
+ },
+ background: () => {
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ return {
+ redirectUrl: browser.runtime.getURL("test.html"),
+ };
+ },
+ { urls: ["*://*/redir"] },
+ ["blocking"]
+ );
+ },
+ });
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "https://example.com/redir"
+ );
+
+ await extension.awaitMessage("test_done");
+
+ await extension.unload();
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js
new file mode 100644
index 0000000000..69238fb057
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js
@@ -0,0 +1,57 @@
+"use strict";
+
+const server = createHttpServer();
+const gServerUrl = `http://localhost:${server.identity.primaryPort}`;
+
+const EXTENSION_DATA = {
+ manifest: {
+ name: "Simple extension test",
+ version: "1.0",
+ manifest_version: 2,
+ description: "",
+
+ permissions: ["webRequest", "<all_urls>"],
+ },
+
+ async background() {
+ browser.test.log("background script running");
+
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ async details => {
+ browser.test.assertTrue(details.requestSize == 0, "no requestSize");
+ browser.test.assertTrue(details.responseSize == 0, "no responseSize");
+ browser.test.log(`details.requestSize: ${details.requestSize}`);
+ browser.test.log(`details.responseSize: ${details.responseSize}`);
+ browser.test.sendMessage("check");
+ },
+ { urls: ["*://*/*"] }
+ );
+
+ browser.webRequest.onCompleted.addListener(
+ async details => {
+ browser.test.assertTrue(details.requestSize > 100, "have requestSize");
+ browser.test.assertTrue(
+ details.responseSize > 100,
+ "have responseSize"
+ );
+ browser.test.log(`details.requestSize: ${details.requestSize}`);
+ browser.test.log(`details.responseSize: ${details.responseSize}`);
+ browser.test.sendMessage("done");
+ },
+ { urls: ["*://*/*"] }
+ );
+ },
+};
+
+add_task(async function test_request_response_size() {
+ let ext = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+ await ext.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${gServerUrl}/dummy`
+ );
+ await ext.awaitMessage("check");
+ await ext.awaitMessage("done");
+ await contentPage.close();
+ await ext.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js
new file mode 100644
index 0000000000..8995870ba6
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js
@@ -0,0 +1,764 @@
+"use strict";
+
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+/* eslint-disable no-shadow */
+
+const { ExtensionTestCommon } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionTestCommon.sys.mjs"
+);
+
+const HOSTS = new Set(["example.com"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+const BASE_URL = "http://example.com";
+const FETCH_ORIGIN = "http://example.com/data/file_sample.html";
+
+const SEQUENTIAL = false;
+
+const PARTS = [
+ `<!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title></title>
+ </head>
+ <body>`,
+ "Lorem ipsum dolor sit amet, <br>",
+ "consectetur adipiscing elit, <br>",
+ "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. <br>",
+ "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. <br>",
+ "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <br>",
+ "Excepteur sint occaecat cupidatat non proident, <br>",
+ "sunt in culpa qui officia deserunt mollit anim id est laborum.<br>",
+ `
+ </body>
+ </html>`,
+].map(part => `${part}\n`);
+
+const TIMEOUT = AppConstants.DEBUG ? 4000 : 800;
+
+function delay(timeout = TIMEOUT) {
+ return new Promise(resolve => setTimeout(resolve, timeout));
+}
+
+server.registerPathHandler("/slow_response.sjs", async (request, response) => {
+ response.processAsync();
+
+ response.setHeader("Content-Type", "text/html", false);
+ response.setHeader("Cache-Control", "no-cache", false);
+
+ await delay();
+
+ for (let part of PARTS) {
+ try {
+ response.write(part);
+ } catch (e) {
+ // This fails if we attempt to write data after the connection has
+ // been closed.
+ break;
+ }
+ await delay();
+ }
+
+ response.finish();
+});
+
+server.registerPathHandler("/lorem.html.gz", async (request, response) => {
+ response.processAsync();
+
+ response.setHeader(
+ "Content-Type",
+ "Content-Type: text/html; charset=utf-8",
+ false
+ );
+ response.setHeader("Content-Encoding", "gzip", false);
+
+ let data = await IOUtils.read(do_get_file("data/lorem.html.gz").path);
+ response.write(String.fromCharCode(...data));
+
+ response.finish();
+});
+
+server.registerPathHandler("/multipart", async (request, response) => {
+ response.processAsync();
+
+ response.setHeader(
+ "Content-Type",
+ 'Content-Type: multipart/x-mixed-replace; boundary="testingtesting"',
+ false
+ );
+
+ response.write("--testingtesting\n");
+ response.write(PARTS.join(""));
+ response.write("--testingtesting--\n");
+
+ response.finish();
+});
+
+server.registerPathHandler("/multipart2", async (request, response) => {
+ response.processAsync();
+
+ response.setHeader(
+ "Content-Type",
+ 'Content-Type: multipart/x-mixed-replace; boundary="testingtesting"',
+ false
+ );
+
+ response.write("--testingtesting\n");
+ response.write(PARTS.join(""));
+ response.write("--testingtesting\n");
+ response.write(PARTS.join(""));
+ response.write("--testingtesting--\n");
+
+ response.finish();
+});
+
+server.registerDirectory("/data/", do_get_file("data"));
+
+const TASKS = [
+ {
+ url: "slow_response.sjs",
+ task(filter, resolve, num) {
+ let decoder = new TextDecoder("utf-8");
+
+ browser.test.assertEq(
+ "uninitialized",
+ filter.status,
+ `(${num}): Got expected initial status`
+ );
+
+ filter.onstart = event => {
+ browser.test.assertEq(
+ "transferringdata",
+ filter.status,
+ `(${num}): Got expected onStart status`
+ );
+ };
+
+ filter.onstop = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected onStop event while disconnected`
+ );
+ };
+
+ let n = 0;
+ filter.ondata = async event => {
+ let str = decoder.decode(event.data, { stream: true });
+
+ if (n < 3) {
+ browser.test.assertEq(
+ JSON.stringify(PARTS[n]),
+ JSON.stringify(str),
+ `(${num}): Got expected part`
+ );
+ }
+ n++;
+
+ filter.write(event.data);
+
+ if (n == 3) {
+ filter.suspend();
+
+ browser.test.assertEq(
+ "suspended",
+ filter.status,
+ `(${num}): Got expected suspended status`
+ );
+
+ let fail = () => {
+ browser.test.fail(
+ `(${num}): Got unexpected data event while suspended`
+ );
+ };
+ filter.addEventListener("data", fail);
+
+ await delay(TIMEOUT * 3);
+
+ browser.test.assertEq(
+ "suspended",
+ filter.status,
+ `(${num}): Got expected suspended status`
+ );
+
+ filter.removeEventListener("data", fail);
+ filter.resume();
+ browser.test.assertEq(
+ "transferringdata",
+ filter.status,
+ `(${num}): Got expected resumed status`
+ );
+ } else if (n > 4) {
+ filter.disconnect();
+
+ filter.addEventListener("data", () => {
+ browser.test.fail(
+ `(${num}): Got unexpected data event while disconnected`
+ );
+ });
+
+ browser.test.assertEq(
+ "disconnected",
+ filter.status,
+ `(${num}): Got expected disconnected status`
+ );
+
+ resolve();
+ }
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(response, PARTS.join(""), "Got expected final HTML");
+ },
+ },
+ {
+ url: "slow_response.sjs",
+ task(filter, resolve, num) {
+ let decoder = new TextDecoder("utf-8");
+
+ filter.onstop = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected onStop event while disconnected`
+ );
+ };
+
+ let n = 0;
+ filter.ondata = async event => {
+ let str = decoder.decode(event.data, { stream: true });
+
+ if (n < 3) {
+ browser.test.assertEq(
+ JSON.stringify(PARTS[n]),
+ JSON.stringify(str),
+ `(${num}): Got expected part`
+ );
+ }
+ n++;
+
+ filter.write(event.data);
+
+ if (n == 3) {
+ filter.suspend();
+
+ await delay(TIMEOUT * 3);
+
+ filter.disconnect();
+
+ resolve();
+ }
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(response, PARTS.join(""), "Got expected final HTML");
+ },
+ },
+ {
+ url: "slow_response.sjs",
+ task(filter, resolve, num) {
+ let encoder = new TextEncoder();
+
+ filter.onstop = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected onStop event while disconnected`
+ );
+ };
+
+ let n = 0;
+ filter.ondata = async event => {
+ n++;
+
+ filter.write(event.data);
+
+ function checkState(state) {
+ browser.test.assertEq(
+ state,
+ filter.status,
+ `(${num}): Got expected status`
+ );
+ }
+ if (n == 3) {
+ filter.resume();
+ checkState("transferringdata");
+ filter.suspend();
+ checkState("suspended");
+ filter.suspend();
+ checkState("suspended");
+ filter.resume();
+ checkState("transferringdata");
+ filter.suspend();
+ checkState("suspended");
+
+ await delay(TIMEOUT * 3);
+
+ checkState("suspended");
+ filter.disconnect();
+ checkState("disconnected");
+
+ for (let method of ["suspend", "resume", "close"]) {
+ browser.test.assertThrows(
+ () => {
+ filter[method]();
+ },
+ /.*/,
+ `(${num}): ${method}() should throw while disconnected`
+ );
+ }
+
+ browser.test.assertThrows(
+ () => {
+ filter.write(encoder.encode("Foo bar"));
+ },
+ /.*/,
+ `(${num}): write() should throw while disconnected`
+ );
+
+ filter.disconnect();
+
+ resolve();
+ }
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(response, PARTS.join(""), "Got expected final HTML");
+ },
+ },
+ {
+ url: "slow_response.sjs",
+ task(filter, resolve, num) {
+ let encoder = new TextEncoder();
+ let decoder = new TextDecoder("utf-8");
+
+ filter.onstop = event => {
+ browser.test.fail(`(${num}): Got unexpected onStop event while closed`);
+ };
+
+ browser.test.assertThrows(
+ () => {
+ filter.write(encoder.encode("Foo bar"));
+ },
+ /.*/,
+ `(${num}): write() should throw prior to connection`
+ );
+
+ let n = 0;
+ filter.ondata = async event => {
+ n++;
+
+ filter.write(event.data);
+
+ browser.test.log(
+ `(${num}): Got part ${n}: ${JSON.stringify(
+ decoder.decode(event.data)
+ )}`
+ );
+
+ function checkState(state) {
+ browser.test.assertEq(
+ state,
+ filter.status,
+ `(${num}): Got expected status`
+ );
+ }
+ if (n == 3) {
+ filter.close();
+
+ checkState("closed");
+
+ for (let method of ["suspend", "resume", "disconnect"]) {
+ browser.test.assertThrows(
+ () => {
+ filter[method]();
+ },
+ /.*/,
+ `(${num}): ${method}() should throw while closed`
+ );
+ }
+
+ browser.test.assertThrows(
+ () => {
+ filter.write(encoder.encode("Foo bar"));
+ },
+ /.*/,
+ `(${num}): write() should throw while closed`
+ );
+
+ filter.close();
+
+ resolve();
+ }
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(response, PARTS.slice(0, 3).join(""), "Got expected final HTML");
+ },
+ },
+ {
+ url: "lorem.html.gz",
+ task(filter, resolve, num) {
+ let response = "";
+ let decoder = new TextDecoder("utf-8");
+
+ filter.onstart = event => {
+ browser.test.log(`(${num}): Request start`);
+ };
+
+ filter.onstop = event => {
+ browser.test.assertEq(
+ "finishedtransferringdata",
+ filter.status,
+ `(${num}): Got expected onStop status`
+ );
+
+ filter.close();
+ browser.test.assertEq(
+ "closed",
+ filter.status,
+ `Got expected closed status`
+ );
+
+ browser.test.assertEq(
+ JSON.stringify(PARTS.join("")),
+ JSON.stringify(response),
+ `(${num}): Got expected response`
+ );
+
+ resolve();
+ };
+
+ filter.ondata = event => {
+ let str = decoder.decode(event.data, { stream: true });
+ response += str;
+
+ filter.write(event.data);
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(response, PARTS.join(""), "Got expected final HTML");
+ },
+ },
+ {
+ url: "multipart",
+ task(filter, resolve, num) {
+ filter.onstart = event => {
+ browser.test.log(`(${num}): Request start`);
+ };
+
+ filter.onstop = event => {
+ filter.disconnect();
+ resolve();
+ };
+
+ filter.ondata = event => {
+ filter.write(event.data);
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(
+ response,
+ "--testingtesting\n" + PARTS.join("") + "--testingtesting--\n",
+ "Got expected final HTML"
+ );
+ },
+ },
+ {
+ url: "multipart2",
+ task(filter, resolve, num) {
+ filter.onstart = event => {
+ browser.test.log(`(${num}): Request start`);
+ };
+
+ filter.onstop = event => {
+ filter.disconnect();
+ resolve();
+ };
+
+ filter.ondata = event => {
+ filter.write(event.data);
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(
+ response,
+ "--testingtesting\n" +
+ PARTS.join("") +
+ "--testingtesting\n" +
+ PARTS.join("") +
+ "--testingtesting--\n",
+ "Got expected final HTML"
+ );
+ },
+ },
+];
+
+function serializeTest(test, num) {
+ let url = `${test.url}?test_num=${num}`;
+ let task = ExtensionTestCommon.serializeFunction(test.task);
+
+ return `{url: ${JSON.stringify(url)}, task: ${task}}`;
+}
+
+add_task(async function () {
+ function background(TASKS) {
+ async function runTest(test, num, details) {
+ browser.test.log(`Running test #${num}: ${details.url}`);
+
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+
+ try {
+ await new Promise(resolve => {
+ test.task(filter, resolve, num, details);
+ });
+ } catch (e) {
+ browser.test.fail(
+ `Task #${num} threw an unexpected exception: ${e} :: ${e.stack}`
+ );
+ }
+
+ browser.test.log(`Finished test #${num}: ${details.url}`);
+ browser.test.sendMessage(`finished-${num}`);
+ }
+
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ for (let [num, test] of TASKS.entries()) {
+ if (details.url.endsWith(test.url)) {
+ runTest(test, num, details);
+ break;
+ }
+ }
+ },
+ {
+ urls: ["http://example.com/*?test_num=*"],
+ },
+ ["blocking"]
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `
+ const PARTS = ${JSON.stringify(PARTS)};
+ const TIMEOUT = ${TIMEOUT};
+
+ ${delay}
+
+ (${background})([${TASKS.map(serializeTest)}])
+ `,
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ async function runTest(test, num) {
+ let url = `${BASE_URL}/${test.url}?test_num=${num}`;
+
+ let body = await ExtensionTestUtils.fetch(FETCH_ORIGIN, url);
+
+ await extension.awaitMessage(`finished-${num}`);
+
+ info(`Verifying test #${num}: ${url}`);
+ await test.verify(body);
+ }
+
+ if (SEQUENTIAL) {
+ for (let [num, test] of TASKS.entries()) {
+ await runTest(test, num);
+ }
+ } else {
+ await Promise.all(TASKS.map(runTest));
+ }
+
+ await extension.unload();
+});
+
+// Test that registering a listener for a cached response does not cause a crash.
+add_task(async function test_cachedResponse() {
+ if (AppConstants.platform === "android") {
+ return;
+ }
+ Services.prefs.setBoolPref("network.http.rcwn.enabled", false);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onHeadersReceived.addListener(
+ data => {
+ let filter = browser.webRequest.filterResponseData(data.requestId);
+
+ filter.onstop = event => {
+ filter.close();
+ };
+ filter.ondata = event => {
+ filter.write(event.data);
+ };
+
+ if (data.fromCache) {
+ browser.test.sendMessage("from-cache");
+ }
+ },
+ {
+ urls: ["http://example.com/*/file_sample.html?r=*"],
+ },
+ ["blocking"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`;
+ await ExtensionTestUtils.fetch(FETCH_ORIGIN, url);
+ await ExtensionTestUtils.fetch(FETCH_ORIGIN, url);
+ await extension.awaitMessage("from-cache");
+
+ await extension.unload();
+});
+
+// Test that finishing transferring data doesn't overwrite an existing closing/closed state.
+add_task(async function test_late_close() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ data => {
+ let filter = browser.webRequest.filterResponseData(data.requestId);
+
+ filter.onstop = event => {
+ browser.test.fail("Should not receive onstop after close()");
+ browser.test.assertEq(
+ "closed",
+ filter.status,
+ "Filter status should still be 'closed'"
+ );
+ browser.test.assertThrows(() => {
+ filter.close();
+ });
+ };
+ filter.ondata = event => {
+ filter.write(event.data);
+ filter.close();
+
+ browser.test.sendMessage(`done-${data.url}`);
+ };
+ },
+ {
+ urls: ["http://example.com/*/file_sample.html?*"],
+ },
+ ["blocking"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ // This issue involves a race, so several requests in parallel to increase
+ // the chances of triggering it.
+ let urls = [];
+ for (let i = 0; i < 32; i++) {
+ urls.push(`${BASE_URL}/data/file_sample.html?r=${Math.random()}`);
+ }
+
+ await Promise.all(
+ urls.map(url => ExtensionTestUtils.fetch(FETCH_ORIGIN, url))
+ );
+ await Promise.all(urls.map(url => extension.awaitMessage(`done-${url}`)));
+
+ await extension.unload();
+});
+
+add_task(async function test_permissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.assertEq(
+ undefined,
+ browser.webRequest.filterResponseData,
+ "filterResponseData is undefined without blocking permissions"
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+ await extension.unload();
+});
+
+add_task(async function test_invalidId() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let filter = browser.webRequest.filterResponseData("34159628");
+
+ await new Promise(resolve => {
+ filter.onerror = resolve;
+ });
+
+ browser.test.assertEq(
+ "Invalid request ID",
+ filter.error,
+ "Got expected error"
+ );
+
+ browser.test.notifyPass("invalid-request-id");
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("invalid-request-id");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_restrictedHeaders.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_restrictedHeaders.js
new file mode 100644
index 0000000000..471bae0493
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_restrictedHeaders.js
@@ -0,0 +1,252 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+const server = createHttpServer({
+ hosts: ["example.net"],
+});
+server.registerPathHandler("/test/response-header", (req, res) => {
+ let headerName;
+ let headerValue;
+ if (req.queryString) {
+ let params = new URLSearchParams(req.queryString);
+ headerName = params.get("name");
+ headerValue = params.get("value");
+ res.setHeader(headerName, headerValue, false);
+ res.setHeader("test", `${headerName}=${headerValue}`, false);
+ }
+ res.write("");
+});
+
+const extensionData = {
+ useAddonManager: "temporary",
+ background() {
+ const { manifest_version } = browser.runtime.getManifest();
+ let headerToSet = undefined;
+ browser.test.onMessage.addListener(function (msg, arg) {
+ if (msg !== "header-to-set") {
+ return;
+ }
+ headerToSet = arg;
+ browser.test.sendMessage("header-to-set:done");
+ });
+ browser.webRequest.onHeadersReceived.addListener(
+ e => {
+ browser.test.log(`onHeadersReceived ${e.requestId} ${e.url}`);
+ if (headerToSet === undefined) {
+ browser.test.fail(
+ "extension called before headerToSet option was set"
+ );
+ }
+ if (typeof headerToSet?.name == "string") {
+ const existingHeader = e.responseHeaders.filter(
+ i => i.name.toLowerCase() === headerToSet.name
+ )[0];
+ e.responseHeaders = e.responseHeaders.filter(
+ i => i.name.toLowerCase() != headerToSet.name
+ );
+ // Omit the header if the value isn't set, change the header otherwise.
+ if (headerToSet.value != null) {
+ e.responseHeaders.push({
+ name: headerToSet.name,
+ value: headerToSet.value,
+ });
+ }
+ browser.test.log(
+ `Test Extension MV${manifest_version} (${browser.runtime.id}) sets responseHeader: "${headerToSet.name}"="${headerToSet.value}" (was originally set to "${existingHeader?.value})"`
+ );
+ }
+ return { responseHeaders: e.responseHeaders };
+ },
+ { urls: ["*://example.net/test/*"] },
+ ["blocking", "responseHeaders"]
+ );
+ browser.webRequest.onCompleted.addListener(
+ e => {
+ browser.test.log(`onCompletedReceived ${e.requestId} ${e.url}`);
+ const responseHeaders = e.responseHeaders.filter(
+ i => i.name.toLowerCase() === headerToSet.name
+ );
+
+ browser.test.sendMessage(
+ "on-completed:response-headers",
+ responseHeaders
+ );
+ },
+ { urls: ["*://example.net/test/*"] },
+ ["responseHeaders"]
+ );
+ browser.test.sendMessage("bgpage:ready");
+ },
+};
+
+const extDataMV2 = {
+ ...extensionData,
+ manifest: {
+ manifest_version: 2,
+ permissions: ["webRequest", "webRequestBlocking", "*://example.net/test/*"],
+ },
+};
+
+const extDataMV3 = {
+ ...extensionData,
+ manifest: {
+ manifest_version: 3,
+ permissions: ["webRequest", "webRequestBlocking"],
+ host_permissions: ["*://example.net/test/*"],
+ granted_host_permissions: true,
+ },
+};
+
+add_setup(async () => {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+async function test_restricted_response_headers_changes({
+ firstExtData,
+ secondExtData,
+ headerName,
+ firstExtHeaderChange,
+ secondExtHeaderChange,
+ siteHeaderValue,
+ expectedHeaderValue,
+}) {
+ const ext1 = ExtensionTestUtils.loadExtension(firstExtData);
+ const ext2 = secondExtData && ExtensionTestUtils.loadExtension(secondExtData);
+
+ await ext1.startup();
+ await ext1.awaitMessage("bgpage:ready");
+
+ await ext2?.startup();
+ await ext2?.awaitMessage("bgpage:ready");
+
+ ext1.sendMessage("header-to-set", {
+ name: headerName,
+ value: firstExtHeaderChange,
+ });
+ await ext1.awaitMessage("header-to-set:done");
+ ext2?.sendMessage("header-to-set", {
+ name: headerName,
+ value: secondExtHeaderChange,
+ });
+ await ext2?.awaitMessage("header-to-set:done");
+
+ if (siteHeaderValue) {
+ await ExtensionTestUtils.fetch(
+ "http://example.net/",
+ `http://example.net/test/response-header?name=${headerName}&value=${siteHeaderValue}`
+ );
+ } else {
+ await ExtensionTestUtils.fetch(
+ "http://example.net/",
+ "http://example.net/test/response-header"
+ );
+ }
+
+ const [finalSiteHeaders] = await Promise.all([
+ ext1.awaitMessage("on-completed:response-headers"),
+ ext2?.awaitMessage("on-completed:response-headers"),
+ ]);
+
+ Assert.deepEqual(
+ finalSiteHeaders,
+ expectedHeaderValue
+ ? [{ name: headerName, value: expectedHeaderValue }]
+ : [],
+ "Got the expected response header"
+ );
+
+ await ext1.unload();
+ await ext2?.unload();
+}
+
+add_task(async function test_changes_to_restricted_response_headers() {
+ const testCases = [
+ {
+ headerName: "cross-origin-embedder-policy",
+ siteHeaderValue: "require-corp",
+ firstExtHeaderChange: "credentialless",
+ secondExtHeaderChange: "unsafe-none",
+ },
+ {
+ headerName: "cross-origin-opener-policy",
+ siteHeaderValue: "same-origin",
+ firstExtHeaderChange: "same-origin-allow-popups",
+ secondExtHeaderChange: "unsafe-none",
+ },
+ {
+ headerName: "cross-origin-resource-policy",
+ siteHeaderValue: "same-origin",
+ firstExtHeaderChange: "same-site",
+ secondExtHeaderChange: "cross-origin",
+ },
+ {
+ headerName: "x-frame-options",
+ siteHeaderValue: "deny",
+ firstExtHeaderChange: "sameorigin",
+ secondExtHeaderChange: "allow-from=http://example.com",
+ },
+ {
+ headerName: "access-control-allow-credentials",
+ siteHeaderValue: "true",
+ firstExtHeaderChange: "false",
+ secondExtHeaderChange: "false",
+ },
+ {
+ headerName: "access-control-allow-methods",
+ siteHeaderValue: "*",
+ firstExtHeaderChange: "",
+ secondExtHeaderChange: "GET",
+ },
+ ];
+
+ for (const testCase of testCases) {
+ info(
+ `Test MV3 extension disallowed to change restricted header if already set by the website: "${testCase.headerName}"="${testCase.siteHeaderValue}`
+ );
+ await test_restricted_response_headers_changes({
+ ...testCase,
+ firstExtData: extDataMV3,
+ // Expect the value set by the server to be preserved.
+ expectedHeaderValue: testCase.siteHeaderValue,
+ });
+ }
+
+ for (const testCase of testCases) {
+ info(
+ `Test MV3 extension disallowed to change restricted header also if not set by the website: "${testCase.headerName}`
+ );
+ await test_restricted_response_headers_changes({
+ ...testCase,
+ siteHeaderValue: null,
+ firstExtData: extDataMV3,
+ // Expect the value set by the server to be preserved.
+ expectedHeaderValue: null,
+ });
+ }
+
+ for (const testCase of testCases) {
+ info(
+ `Test MV2 extension allowed to change restricted header if already set by the website: ${JSON.stringify(
+ testCase.siteHeader
+ )}`
+ );
+ await test_restricted_response_headers_changes({
+ ...testCase,
+ firstExtData: extDataMV3,
+ secondExtData: extDataMV2,
+ // Expect the value set by the server to be preserved.
+ expectedHeaderValue: testCase.secondExtHeaderChange,
+ });
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js
new file mode 100644
index 0000000000..e40bc4f8b4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js
@@ -0,0 +1,308 @@
+"use strict";
+
+const HOSTS = new Set(["example.com"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+server.registerDirectory("/data/", do_get_file("data"));
+
+server.registerPathHandler(
+ "/file_webrequestblocking_set_cookie.html",
+ (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.setHeader("Set-Cookie", "reqcookie=reqvalue", false);
+ response.write("<!DOCTYPE html><html></html>");
+ }
+);
+
+add_task(async function test_modifying_cookies_from_onHeadersReceived() {
+ async function background() {
+ /**
+ * Check that all the cookies described by `prefixes` are in the cookie jar.
+ *
+ * @param {Array.string} prefixes
+ * Zero or more prefixes, describing cookies that are expected to be set
+ * in the current cookie jar. Each prefix describes both a cookie
+ * name and corresponding value. For example, if the string "ext"
+ * is passed as an argument, then this function expects to see
+ * a cookie called "extcookie" and corresponding value of "extvalue".
+ */
+ async function checkCookies(prefixes) {
+ const numPrefixes = prefixes.length;
+ const currentCookies = await browser.cookies.getAll({});
+ browser.test.assertEq(
+ numPrefixes,
+ currentCookies.length,
+ `${numPrefixes} cookies were set`
+ );
+
+ for (let cookiePrefix of prefixes) {
+ let cookieName = `${cookiePrefix}cookie`;
+ let expectedCookieValue = `${cookiePrefix}value`;
+ let fetchedCookie = await browser.cookies.getAll({ name: cookieName });
+ browser.test.assertEq(
+ 1,
+ fetchedCookie.length,
+ `Found 1 cookie with name "${cookieName}"`
+ );
+ browser.test.assertEq(
+ expectedCookieValue,
+ fetchedCookie[0] && fetchedCookie[0].value,
+ `Cookie "${cookieName}" has expected value of "${expectedCookieValue}"`
+ );
+ }
+ }
+
+ function awaitMessage(expectedMsg) {
+ return new Promise(resolve => {
+ browser.test.onMessage.addListener(function listener(msg) {
+ if (msg === expectedMsg) {
+ browser.test.onMessage.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+ }
+
+ /**
+ * Opens the given test file as a content page.
+ *
+ * @param {string} filename
+ * The name of a html file relative to the test server root.
+ *
+ * @returns {Promise}
+ */
+ function openContentPage(filename) {
+ let promise = awaitMessage("url-loaded");
+ browser.test.sendMessage(
+ "load-url",
+ `http://example.com/${filename}?nocache=${Math.random()}`
+ );
+ return promise;
+ }
+
+ /**
+ * Tests that expected cookies are in the cookie jar after opening a file.
+ *
+ * @param {string} filename
+ * The name of a html file in the
+ * "toolkit/components/extensions/test/mochitest" directory.
+ * @param {?Array.string} prefixes
+ * Zero or more prefixes, describing cookies that are expected to be set
+ * in the current cookie jar. Each prefix describes both a cookie
+ * name and corresponding value. For example, if the string "ext"
+ * is passed as an argument, then this function expects to see
+ * a cookie called "extcookie" and corresponding value of "extvalue".
+ * If undefined, then no checks are automatically performed, and the
+ * caller should provide a callback to perform the checks.
+ * @param {?Function} callback
+ * An optional async callback function that, if provided, will be called
+ * with an object that contains windowId and tabId parameters.
+ * Callers can use this callback to apply extra tests about the state of
+ * the cookie jar, or to query the state of the opened page.
+ */
+ async function testCookiesWithFile(filename, prefixes, callback) {
+ await browser.browsingData.removeCookies({});
+ await openContentPage(filename);
+
+ if (prefixes !== undefined) {
+ await checkCookies(prefixes);
+ }
+
+ if (callback !== undefined) {
+ await callback();
+ }
+ let promise = awaitMessage("url-unloaded");
+ browser.test.sendMessage("unload-url");
+ await promise;
+ }
+
+ const filter = {
+ urls: ["<all_urls>"],
+ types: ["main_frame", "sub_frame"],
+ };
+
+ const headersReceivedInfoSpec = ["blocking", "responseHeaders"];
+
+ const onHeadersReceived = details => {
+ details.responseHeaders.push({
+ name: "Set-Cookie",
+ value: "extcookie=extvalue",
+ });
+
+ return {
+ responseHeaders: details.responseHeaders,
+ };
+ };
+ browser.webRequest.onHeadersReceived.addListener(
+ onHeadersReceived,
+ filter,
+ headersReceivedInfoSpec
+ );
+
+ // First, perform a request that should not set any cookies, and check
+ // that the cookie the extension sets is the only cookie in the
+ // cookie jar.
+ await testCookiesWithFile("data/file_sample.html", ["ext"]);
+
+ // Next, perform a request that will set on cookie (reqcookie=reqvalue)
+ // and check that two cookies wind up in the cookie jar (the request
+ // set cookie, and the extension set cookie).
+ await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [
+ "ext",
+ "req",
+ ]);
+
+ // Third, register another onHeadersReceived handler that also
+ // sets a cookie (thirdcookie=thirdvalue), to make sure modifications from
+ // multiple onHeadersReceived listeners are merged correctly.
+ const thirdOnHeadersRecievedListener = details => {
+ details.responseHeaders.push({
+ name: "Set-Cookie",
+ value: "thirdcookie=thirdvalue",
+ });
+
+ browser.test.log(JSON.stringify(details.responseHeaders));
+
+ return {
+ responseHeaders: details.responseHeaders,
+ };
+ };
+ browser.webRequest.onHeadersReceived.addListener(
+ thirdOnHeadersRecievedListener,
+ filter,
+ headersReceivedInfoSpec
+ );
+ await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [
+ "ext",
+ "req",
+ "third",
+ ]);
+ browser.webRequest.onHeadersReceived.removeListener(onHeadersReceived);
+ browser.webRequest.onHeadersReceived.removeListener(
+ thirdOnHeadersRecievedListener
+ );
+
+ // Fourth, test to make sure that extensions can remove cookies
+ // using onHeadersReceived too, by 1. making a request that
+ // sets a cookie (reqcookie=reqvalue), 2. having the extension remove
+ // that cookie by removing that header, and 3. adding a new cookie
+ // (extcookie=extvalue).
+ const fourthOnHeadersRecievedListener = details => {
+ // Remove the cookie set by the request (reqcookie=reqvalue).
+ const newHeaders = details.responseHeaders.filter(
+ cookie => cookie.name !== "set-cookie"
+ );
+
+ // And then add a new cookie in its place (extcookie=extvalue).
+ newHeaders.push({
+ name: "Set-Cookie",
+ value: "extcookie=extvalue",
+ });
+
+ return {
+ responseHeaders: newHeaders,
+ };
+ };
+ browser.webRequest.onHeadersReceived.addListener(
+ fourthOnHeadersRecievedListener,
+ filter,
+ headersReceivedInfoSpec
+ );
+ await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [
+ "ext",
+ ]);
+ browser.webRequest.onHeadersReceived.removeListener(
+ fourthOnHeadersRecievedListener
+ );
+
+ // Fifth, check that extensions are able to overwrite headers set by
+ // pages. In this test, make a request that will set "reqcookie=reqvalue",
+ // and add a listener that sets "reqcookie=changedvalue". Check
+ // to make sure that the cookie jar contains "reqcookie=changedvalue"
+ // and not "reqcookie=reqvalue".
+ const fifthOnHeadersRecievedListener = details => {
+ // Remove the cookie set by the request (reqcookie=reqvalue).
+ const newHeaders = details.responseHeaders.filter(
+ cookie => cookie.name !== "set-cookie"
+ );
+
+ // And then add a new cookie in its place (reqcookie=changedvalue).
+ newHeaders.push({
+ name: "Set-Cookie",
+ value: "reqcookie=changedvalue",
+ });
+
+ return {
+ responseHeaders: newHeaders,
+ };
+ };
+ browser.webRequest.onHeadersReceived.addListener(
+ fifthOnHeadersRecievedListener,
+ filter,
+ headersReceivedInfoSpec
+ );
+
+ await testCookiesWithFile(
+ "file_webrequestblocking_set_cookie.html",
+ undefined,
+ async () => {
+ const currentCookies = await browser.cookies.getAll({});
+ browser.test.assertEq(1, currentCookies.length, `1 cookie was set`);
+
+ const cookieName = "reqcookie";
+ const expectedCookieValue = "changedvalue";
+ const fetchedCookie = await browser.cookies.getAll({
+ name: cookieName,
+ });
+
+ browser.test.assertEq(
+ 1,
+ fetchedCookie.length,
+ `Found 1 cookie with name "${cookieName}"`
+ );
+ browser.test.assertEq(
+ expectedCookieValue,
+ fetchedCookie[0] && fetchedCookie[0].value,
+ `Cookie "${cookieName}" has expected value of "${expectedCookieValue}"`
+ );
+ }
+ );
+ browser.webRequest.onHeadersReceived.removeListener(
+ fifthOnHeadersRecievedListener
+ );
+
+ browser.test.notifyPass("cookie modifying extension");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "browsingData",
+ "cookies",
+ "webNavigation",
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ ],
+ },
+ background,
+ });
+
+ let contentPage = null;
+ extension.onMessage("load-url", async url => {
+ ok(!contentPage, "Should have no content page to unload");
+ contentPage = await ExtensionTestUtils.loadContentPage(url);
+ extension.sendMessage("url-loaded");
+ });
+ extension.onMessage("unload-url", async () => {
+ await contentPage.close();
+ contentPage = null;
+ extension.sendMessage("url-unloaded");
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("cookie modifying extension");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js
new file mode 100644
index 0000000000..616dc1fb50
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js
@@ -0,0 +1,751 @@
+"use strict";
+
+// Delay loading until createAppInfo is called and setup.
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+// The app and platform version here should be >= of the version set in the extensions.webExtensionsMinPlatformVersion preference,
+// otherwise test_persistent_listener_after_staged_update will fail because no compatible updates will be found.
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+let { promiseShutdownManager, promiseStartupManager, promiseRestartManager } =
+ AddonTestUtils;
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+
+function trackEvents(wrapper) {
+ let events = new Map();
+ for (let event of ["background-script-event", "start-background-script"]) {
+ events.set(event, false);
+ wrapper.extension.once(event, () => events.set(event, true));
+ }
+ return events;
+}
+
+/**
+ * That that we get the expected events
+ *
+ * @param {Extension} extension
+ * @param {Map} events
+ * @param {object} expect
+ * @param {boolean} expect.background delayed startup event expected
+ * @param {boolean} expect.started background has already started
+ * @param {boolean} expect.delayedStart startup is delayed, notify start and
+ * expect the starting event
+ * @param {boolean} expect.request wait for the request event
+ */
+async function testPersistentRequestStartup(extension, events, expect = {}) {
+ equal(
+ events.get("background-script-event"),
+ !!expect.background,
+ "Should have gotten a background script event"
+ );
+ equal(
+ events.get("start-background-script"),
+ !!expect.started,
+ "Background script should be started"
+ );
+
+ if (!expect.started) {
+ AddonTestUtils.notifyEarlyStartup();
+ await ExtensionParent.browserPaintedPromise;
+
+ equal(
+ events.get("start-background-script"),
+ !!expect.delayedStart,
+ "Should have gotten start-background-script event"
+ );
+ }
+
+ if (expect.request) {
+ await extension.awaitMessage("got-request");
+ ok(true, "Background page loaded and received webRequest event");
+ }
+}
+
+// Test that a non-blocking listener does not start the background on
+// startup, but that it does work after startup.
+add_task(async function test_nonblocking() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["webRequest", "http://example.com/"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("got-request");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] }
+ );
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ // First install runs background immediately, this sets persistent listeners
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ // Restart to get APP_STARTUP, the background should not start
+ await promiseRestartManager({ lateStartup: false });
+ await extension.awaitStartup();
+ assertPersistentListeners(extension, "webRequest", "onBeforeRequest", {
+ primed: false,
+ });
+
+ // Test an early startup event
+ let events = trackEvents(extension);
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+
+ await testPersistentRequestStartup(extension, events, {
+ background: false,
+ delayedStart: false,
+ request: false,
+ });
+
+ AddonTestUtils.notifyLateStartup();
+ await extension.awaitMessage("ready");
+ assertPersistentListeners(extension, "webRequest", "onBeforeRequest", {
+ primed: false,
+ });
+
+ // Test an event after startup
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+
+ await testPersistentRequestStartup(extension, events, {
+ background: false,
+ started: true,
+ request: true,
+ });
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
+
+// Test that a non-blocking listener does not start the background on
+// startup, but that it does work after startup.
+add_task(async function test_eventpage_nonblocking() {
+ Services.prefs.setBoolPref("extensions.eventPages.enabled", true);
+ await promiseStartupManager();
+
+ let id = "event-nonblocking@test";
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ permissions: ["webRequest", "http://example.com/"],
+ background: { persistent: false },
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("got-request");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] }
+ );
+ },
+ });
+
+ // First install runs background immediately, this sets persistent listeners
+ await extension.startup();
+
+ // Restart to get APP_STARTUP, the background should not start
+ await promiseRestartManager({ lateStartup: false });
+ await extension.awaitStartup();
+ assertPersistentListeners(extension, "webRequest", "onBeforeRequest", {
+ primed: false,
+ });
+
+ // Test an early startup event
+ let events = trackEvents(extension);
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+
+ await testPersistentRequestStartup(extension, events);
+
+ await AddonTestUtils.notifyLateStartup();
+ // After late startup, event page listeners should be primed.
+ assertPersistentListeners(extension, "webRequest", "onBeforeRequest", {
+ primed: true,
+ });
+
+ // We should not have seen any events yet.
+ await testPersistentRequestStartup(extension, events);
+
+ // Test an event after startup
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+
+ // Now the event page should be started and we'll see the request.
+ await testPersistentRequestStartup(extension, events, {
+ background: true,
+ started: true,
+ request: true,
+ });
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+ Services.prefs.setBoolPref("extensions.eventPages.enabled", false);
+});
+
+// Tests that filters are handled properly: if we have a blocking listener
+// with a filter, a request that does not match the filter does not get
+// suspended and does not start the background page.
+add_task(async function test_persistent_blocking() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "http://test1.example.com/",
+ ],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.fail("Listener should not have been called");
+ },
+ { urls: ["http://test1.example.com/*"] },
+ ["blocking"]
+ );
+ },
+ });
+
+ await extension.startup();
+ assertPersistentListeners(extension, "webRequest", "onBeforeRequest", {
+ primed: false,
+ });
+
+ await promiseRestartManager({ lateStartup: false });
+ await extension.awaitStartup();
+ assertPersistentListeners(extension, "webRequest", "onBeforeRequest", {
+ primed: true,
+ });
+
+ let events = trackEvents(extension);
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+
+ await testPersistentRequestStartup(extension, events, {
+ background: false,
+ delayedStart: false,
+ request: false,
+ });
+
+ AddonTestUtils.notifyLateStartup();
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// Tests that moving permission to optional retains permission and that the
+// persistent listeners are used as expected.
+add_task(async function test_persistent_listener_after_sideload_upgrade() {
+ let id = "permission-sideload-upgrade@test";
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id } },
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("got-request");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] },
+ ["blocking"]
+ );
+ },
+ };
+ let xpi = AddonTestUtils.createTempWebExtensionFile(extensionData);
+
+ let extension = ExtensionTestUtils.expectExtension(id);
+ await AddonTestUtils.manuallyInstall(xpi);
+ await promiseStartupManager();
+ await extension.awaitStartup();
+ // Sideload install does not prime listeners
+ assertPersistentListeners(extension, "webRequest", "onBeforeRequest", {
+ primed: false,
+ });
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+ await extension.awaitMessage("got-request");
+
+ await promiseShutdownManager();
+
+ // Prepare a sideload update for the extension.
+ extensionData.manifest.version = "2.0";
+ extensionData.manifest.permissions = ["http://example.com/"];
+ extensionData.manifest.optional_permissions = [
+ "webRequest",
+ "webRequestBlocking",
+ ];
+ xpi = AddonTestUtils.createTempWebExtensionFile(extensionData);
+ await AddonTestUtils.manuallyInstall(xpi);
+
+ await promiseStartupManager();
+ await extension.awaitStartup();
+ // Upgrades start the background when the extension is loaded, so
+ // primed listeners are cleared already and background events are
+ // already completed.
+ assertPersistentListeners(extension, "webRequest", "onBeforeRequest", {
+ primed: false,
+ persisted: true,
+ });
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// Utility to install builtin addon
+async function installBuiltinExtension(extensionData) {
+ let xpi = await AddonTestUtils.createTempWebExtensionFile(extensionData);
+
+ // The built-in location requires a resource: URL that maps to a
+ // jar: or file: URL. This would typically be something bundled
+ // into omni.ja but for testing we just use a temp file.
+ let base = Services.io.newURI(`jar:file:${xpi.path}!/`);
+ let resProto = Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler);
+ resProto.setSubstitution("ext-test", base);
+ return AddonManager.installBuiltinAddon("resource://ext-test/");
+}
+
+function promisePostponeInstall(install) {
+ return new Promise((resolve, reject) => {
+ let listener = {
+ onInstallFailed: () => {
+ install.removeListener(listener);
+ reject(new Error("extension installation should not have failed"));
+ },
+ onInstallEnded: () => {
+ install.removeListener(listener);
+ reject(
+ new Error(
+ `extension installation should not have ended for ${install.addon.id}`
+ )
+ );
+ },
+ onInstallPostponed: () => {
+ install.removeListener(listener);
+ resolve();
+ },
+ };
+
+ install.addListener(listener);
+ install.install();
+ });
+}
+
+// Tests that moving permission to optional retains permission and that the
+// persistent listeners are used as expected.
+add_task(
+ async function test_persistent_listener_after_builtin_location_upgrade() {
+ let id = "permission-builtin-upgrade@test";
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id } },
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "http://example.com/",
+ ],
+ },
+
+ async background() {
+ browser.runtime.onUpdateAvailable.addListener(() => {
+ browser.test.sendMessage("postponed");
+ });
+
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("got-request");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] },
+ ["blocking"]
+ );
+ },
+ };
+ await promiseStartupManager();
+ // If we use an extension wrapper via ExtensionTestUtils.expectExtension
+ // it will continue to handle messages even after the update, resulting
+ // in errors when it receives additional messages without any awaitMessage.
+ let promiseExtension = AddonTestUtils.promiseWebExtensionStartup(id);
+ await installBuiltinExtension(extensionData);
+ let extv1 = await promiseExtension;
+ assertPersistentListeners(
+ { extension: extv1 },
+ "webRequest",
+ "onBeforeRequest",
+ {
+ primed: false,
+ }
+ );
+
+ // Prepare an update for the extension.
+ extensionData.manifest.version = "2.0";
+ let xpi = AddonTestUtils.createTempWebExtensionFile(extensionData);
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ // Install the update and wait for the onUpdateAvailable event to complete.
+ let promiseUpdate = new Promise(resolve =>
+ extv1.once("test-message", (kind, msg) => {
+ if (msg == "postponed") {
+ resolve();
+ }
+ })
+ );
+ await Promise.all([promisePostponeInstall(install), promiseUpdate]);
+ await promiseShutdownManager();
+
+ // restarting allows upgrade to proceed
+ let extension = ExtensionTestUtils.expectExtension(id);
+ await promiseStartupManager();
+ await extension.awaitStartup();
+ // Upgrades start the background when the extension is loaded, so
+ // primed listeners are cleared already and background events are
+ // already completed.
+ assertPersistentListeners(extension, "webRequest", "onBeforeRequest", {
+ primed: false,
+ persisted: true,
+ });
+
+ await extension.unload();
+
+ // remove the builtin addon which will have restarted now.
+ let addon = await AddonManager.getAddonByID(id);
+ await addon.uninstall();
+
+ await promiseShutdownManager();
+ }
+);
+
+// Tests that moving permission to optional during a staged upgrade retains permission
+// and that the persistent listeners are used as expected.
+add_task(async function test_persistent_listener_after_staged_upgrade() {
+ AddonManager.checkUpdateSecurity = false;
+ let id = "persistent-staged-upgrade@test";
+
+ // register an update file.
+ AddonTestUtils.registerJSON(server, "/test_update.json", {
+ addons: {
+ "persistent-staged-upgrade@test": {
+ updates: [
+ {
+ version: "2.0",
+ update_link:
+ "http://example.com/addons/test_settings_staged_restart.xpi",
+ },
+ ],
+ },
+ },
+ });
+
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: { id, update_url: `http://example.com/test_update.json` },
+ },
+ permissions: ["http://example.com/"],
+ optional_permissions: ["webRequest", "webRequestBlocking"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("got-request");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] },
+ ["blocking"]
+ );
+ browser.webRequest.onSendHeaders.addListener(
+ details => {
+ browser.test.sendMessage("got-sendheaders");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] }
+ );
+ // Force a staged updated.
+ browser.runtime.onUpdateAvailable.addListener(async details => {
+ if (details && details.version) {
+ // This should be the version of the pending update.
+ browser.test.assertEq("2.0", details.version, "correct version");
+ browser.test.sendMessage("delay");
+ }
+ });
+ },
+ };
+
+ // Prepare the update first.
+ server.registerFile(
+ `/addons/test_settings_staged_restart.xpi`,
+ AddonTestUtils.createTempWebExtensionFile(extensionData)
+ );
+
+ // Prepare the extension that will be updated.
+ extensionData.manifest.version = "1.0";
+ extensionData.manifest.permissions = [
+ "webRequest",
+ "webRequestBlocking",
+ "http://example.com/",
+ ];
+ delete extensionData.manifest.optional_permissions;
+ extensionData.background = function () {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("got-request");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] },
+ ["blocking"]
+ );
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ browser.test.sendMessage("got-beforesendheaders");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] }
+ );
+ browser.webRequest.onSendHeaders.addListener(
+ details => {
+ browser.test.sendMessage("got-sendheaders");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] }
+ );
+ // Force a staged updated.
+ browser.runtime.onUpdateAvailable.addListener(async details => {
+ if (details && details.version) {
+ // This should be the version of the pending update.
+ browser.test.assertEq("2.0", details.version, "correct version");
+ browser.test.sendMessage("delay");
+ }
+ });
+ };
+
+ await promiseStartupManager();
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ assertPersistentListeners(extension, "webRequest", "onBeforeRequest", {
+ primed: false,
+ });
+ assertPersistentListeners(extension, "webRequest", "onBeforeSendHeaders", {
+ primed: false,
+ });
+ assertPersistentListeners(extension, "webRequest", "onSendHeaders", {
+ primed: false,
+ });
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+ await extension.awaitMessage("got-request");
+ await extension.awaitMessage("got-beforesendheaders");
+ await extension.awaitMessage("got-sendheaders");
+ ok(true, "Initial version received webRequest event");
+
+ let addon = await AddonManager.getAddonByID(id);
+ Assert.equal(addon.version, "1.0", "1.0 is loaded");
+
+ let update = await AddonTestUtils.promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+ Assert.ok(install, `install is available ${update.error}`);
+
+ await AddonTestUtils.promiseCompleteAllInstalls([install]);
+
+ Assert.equal(
+ install.state,
+ AddonManager.STATE_POSTPONED,
+ "update is staged for install"
+ );
+ await extension.awaitMessage("delay");
+
+ await promiseShutdownManager();
+
+ // restarting allows upgrade to proceed
+ await promiseStartupManager();
+ await extension.awaitStartup();
+
+ // Upgrades start the background when the extension is loaded, so
+ // primed listeners are cleared already and background events are
+ // already completed.
+ assertPersistentListeners(extension, "webRequest", "onBeforeRequest", {
+ primed: false,
+ persisted: true,
+ });
+ // this was removed in the upgrade background, should not be persisted.
+ assertPersistentListeners(extension, "webRequest", "onBeforeSendHeaders", {
+ primed: false,
+ persisted: false,
+ });
+ assertPersistentListeners(extension, "webRequest", "onSendHeaders", {
+ primed: false,
+ persisted: true,
+ });
+
+ await extension.unload();
+ await promiseShutdownManager();
+ AddonManager.checkUpdateSecurity = true;
+});
+
+// Tests that removing the permission releases the persistent listener.
+add_task(async function test_persistent_listener_after_permission_removal() {
+ AddonManager.checkUpdateSecurity = false;
+ let id = "persistent-staged-remove@test";
+
+ // register an update file.
+ AddonTestUtils.registerJSON(server, "/test_remove.json", {
+ addons: {
+ "persistent-staged-remove@test": {
+ updates: [
+ {
+ version: "2.0",
+ update_link:
+ "http://example.com/addons/test_settings_staged_remove.xpi",
+ },
+ ],
+ },
+ },
+ });
+
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: { id, update_url: `http://example.com/test_remove.json` },
+ },
+ permissions: ["tabs", "http://example.com/"],
+ },
+
+ background() {
+ browser.test.sendMessage("loaded");
+ },
+ };
+
+ // Prepare the update first.
+ server.registerFile(
+ `/addons/test_settings_staged_remove.xpi`,
+ AddonTestUtils.createTempWebExtensionFile(extensionData)
+ );
+
+ await promiseStartupManager();
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: { id, update_url: `http://example.com/test_remove.json` },
+ },
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("got-request");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] },
+ ["blocking"]
+ );
+ // Force a staged updated.
+ browser.runtime.onUpdateAvailable.addListener(async details => {
+ if (details && details.version) {
+ // This should be the version of the pending update.
+ browser.test.assertEq("2.0", details.version, "correct version");
+ browser.test.sendMessage("delay");
+ }
+ });
+ },
+ });
+
+ await extension.startup();
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+ await extension.awaitMessage("got-request");
+ ok(true, "Initial version received webRequest event");
+
+ let addon = await AddonManager.getAddonByID(id);
+ Assert.equal(addon.version, "1.0", "1.0 is loaded");
+
+ let update = await AddonTestUtils.promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+ Assert.ok(install, `install is available ${update.error}`);
+
+ await AddonTestUtils.promiseCompleteAllInstalls([install]);
+
+ Assert.equal(
+ install.state,
+ AddonManager.STATE_POSTPONED,
+ "update is staged for install"
+ );
+ await extension.awaitMessage("delay");
+
+ await promiseShutdownManager();
+
+ // restarting allows upgrade to proceed
+ await promiseStartupManager({ lateStartup: false });
+ await extension.awaitStartup();
+ await extension.awaitMessage("loaded");
+
+ // Upgrades start the background when the extension is loaded, so
+ // primed listeners are cleared already and background events are
+ // already completed.
+ assertPersistentListeners(extension, "webRequest", "onBeforeRequest", {
+ primed: false,
+ persisted: false,
+ });
+
+ await extension.unload();
+ await promiseShutdownManager();
+ AddonManager.checkUpdateSecurity = true;
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js
new file mode 100644
index 0000000000..4972719b43
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js
@@ -0,0 +1,76 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+let { promiseRestartManager, promiseShutdownManager, promiseStartupManager } =
+ AddonTestUtils;
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+// Test that a blocking listener that uses filterResponseData() works
+// properly (i.e., that the delayed call to registerTraceableChannel
+// works properly).
+add_task(async function test_StreamFilter_at_restart() {
+ const DATA = `<!DOCTYPE html>
+<html>
+<body>
+ <h1>This is a modified page</h1>
+</body>
+</html>`;
+
+ function background(data) {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ filter.onstop = () => {
+ let encoded = new TextEncoder().encode(data);
+ filter.write(encoded);
+ filter.close();
+ };
+ },
+ { urls: ["http://example.com/data/file_sample.html"] },
+ ["blocking"]
+ );
+ }
+
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+
+ background: `(${background})(${uneval(DATA)})`,
+ });
+
+ await extension.startup();
+
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ let dataPromise = ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+
+ Services.obs.notifyObservers(null, "browser-delayed-startup-finished");
+ let data = await dataPromise;
+
+ equal(
+ data,
+ DATA,
+ "Stream filter was properly installed for a load during startup"
+ );
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js
new file mode 100644
index 0000000000..296bee3685
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js
@@ -0,0 +1,49 @@
+"use strict";
+
+const BASE = "http://example.com/data/";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_stylesheet_cache() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background() {
+ const SHEET_URI = "http://example.com/data/file_stylesheet_cache.css";
+ let firstFound = false;
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(
+ details.url,
+ firstFound ? SHEET_URI + "?2" : SHEET_URI
+ );
+ firstFound = true;
+ browser.test.sendMessage("stylesheet found");
+ },
+ { urls: ["<all_urls>"], types: ["stylesheet"] },
+ ["blocking"]
+ );
+ },
+ });
+
+ await extension.startup();
+
+ let cp = await ExtensionTestUtils.loadContentPage(
+ BASE + "file_stylesheet_cache.html"
+ );
+
+ await extension.awaitMessage("stylesheet found");
+
+ // Need to use the same ContentPage so that the remote process the page ends
+ // up in is the same.
+ await cp.loadURL(BASE + "file_stylesheet_cache_2.html");
+
+ await extension.awaitMessage("stylesheet found");
+
+ await cp.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js
new file mode 100644
index 0000000000..f8116aced0
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js
@@ -0,0 +1,289 @@
+"use strict";
+
+const HOSTS = new Set(["example.com"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+const BASE_URL = "http://example.com";
+const FETCH_ORIGIN = "http://example.com/dummy";
+
+server.registerPathHandler("/return_headers.sjs", (request, response) => {
+ response.setHeader("Content-Type", "text/plain", false);
+
+ let headers = {};
+ for (let { data: header } of request.headers) {
+ headers[header.toLowerCase()] = request.getHeader(header);
+ }
+
+ response.write(JSON.stringify(headers));
+});
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+add_task(async function test_suspend() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`],
+ },
+
+ background() {
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ // Make sure that returning undefined or a promise that resolves to
+ // undefined does not break later handlers.
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking", "requestHeaders"]
+ );
+
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ return Promise.resolve();
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking", "requestHeaders"]
+ );
+
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ let requestHeaders = details.requestHeaders.concat({
+ name: "Foo",
+ value: "Bar",
+ });
+
+ return new Promise(resolve => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(resolve, 500);
+ }).then(() => {
+ return { requestHeaders };
+ });
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking", "requestHeaders"]
+ );
+ },
+ });
+
+ await extension.startup();
+
+ let headers = JSON.parse(
+ await ExtensionTestUtils.fetch(
+ FETCH_ORIGIN,
+ `${BASE_URL}/return_headers.sjs`
+ )
+ );
+
+ equal(
+ headers.foo,
+ "Bar",
+ "Request header was correctly set on suspended request"
+ );
+
+ await extension.unload();
+});
+
+// Test that requests that were canceled while suspended for a blocking
+// listener are correctly resumed.
+add_task(async function test_error_resume() {
+ let observer = channel => {
+ if (
+ channel instanceof Ci.nsIHttpChannel &&
+ channel.URI.spec === "http://example.com/dummy"
+ ) {
+ Services.obs.removeObserver(observer, "http-on-before-connect");
+
+ // Wait until the next tick to make sure this runs after WebRequest observers.
+ Promise.resolve().then(() => {
+ channel.cancel(Cr.NS_BINDING_ABORTED);
+ });
+ }
+ };
+
+ Services.obs.addObserver(observer, "http-on-before-connect");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`],
+ },
+
+ background() {
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ browser.test.log(`onBeforeSendHeaders({url: ${details.url}})`);
+
+ if (details.url === "http://example.com/dummy") {
+ browser.test.sendMessage("got-before-send-headers");
+ }
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.log(`onErrorOccurred({url: ${details.url}})`);
+
+ if (details.url === "http://example.com/dummy") {
+ browser.test.sendMessage("got-error-occurred");
+ }
+ },
+ { urls: ["<all_urls>"] }
+ );
+ },
+ });
+
+ await extension.startup();
+
+ try {
+ await ExtensionTestUtils.fetch(FETCH_ORIGIN, `${BASE_URL}/dummy`);
+ ok(false, "Fetch should have failed.");
+ } catch (e) {
+ ok(true, "Got expected error.");
+ }
+
+ await extension.awaitMessage("got-before-send-headers");
+ await extension.awaitMessage("got-error-occurred");
+
+ // Wait for the next tick so the onErrorRecurred response can be
+ // processed before shutting down the extension.
+ await new Promise(resolve => executeSoon(resolve));
+
+ await extension.unload();
+});
+
+// Test that response header modifications take effect before onStartRequest fires.
+add_task(async function test_set_responseHeaders() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+
+ background() {
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ browser.test.log(`onHeadersReceived({url: ${details.url}})`);
+
+ details.responseHeaders.push({ name: "foo", value: "bar" });
+
+ return { responseHeaders: details.responseHeaders };
+ },
+ { urls: ["http://example.com/?modify_headers"] },
+ ["blocking", "responseHeaders"]
+ );
+ },
+ });
+
+ await extension.startup();
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ let resolveHeaderPromise;
+ let headerPromise = new Promise(resolve => {
+ resolveHeaderPromise = resolve;
+ });
+ {
+ let ssm = Services.scriptSecurityManager;
+
+ let channel = NetUtil.newChannel({
+ uri: "http://example.com/?modify_headers",
+ loadingPrincipal:
+ ssm.createContentPrincipalFromOrigin("http://example.com"),
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST,
+ securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ });
+
+ channel.asyncOpen({
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+
+ onStartRequest(request) {
+ request.QueryInterface(Ci.nsIHttpChannel);
+
+ try {
+ resolveHeaderPromise(request.getResponseHeader("foo"));
+ } catch (e) {
+ resolveHeaderPromise(null);
+ }
+ request.cancel(Cr.NS_BINDING_ABORTED);
+ },
+
+ onStopRequest() {},
+
+ onDataAvailable() {
+ throw new Components.Exception("", Cr.NS_ERROR_FAILURE);
+ },
+ });
+ }
+
+ let headerValue = await headerPromise;
+ equal(headerValue, "bar", "Expected Foo header value");
+
+ await extension.unload();
+});
+
+// Test that exceptions raised from a blocking webRequest listener that returns
+// a promise are logged as expected.
+add_task(async function test_logged_error_on_promise_result() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`],
+ },
+
+ background() {
+ async function onBeforeRequest() {
+ throw new Error("Expected webRequest exception from a promise result");
+ }
+
+ let exceptionRaised = false;
+
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ if (exceptionRaised) {
+ return;
+ }
+
+ // We only need to raise the exception once.
+ exceptionRaised = true;
+ return onBeforeRequest();
+ },
+ {
+ urls: ["http://example.com/*"],
+ types: ["main_frame"],
+ },
+ ["blocking"]
+ );
+
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ browser.test.sendMessage("web-request-event-received");
+ },
+ {
+ urls: ["http://example.com/*"],
+ types: ["main_frame"],
+ },
+ ["blocking"]
+ );
+ },
+ });
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/dummy`
+ );
+ await extension.awaitMessage("web-request-event-received");
+ await contentPage.close();
+ });
+
+ ok(
+ messages.some(msg =>
+ /Expected webRequest exception from a promise result/.test(msg.message)
+ ),
+ "Got expected console message"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js
new file mode 100644
index 0000000000..de2d059d96
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js
@@ -0,0 +1,45 @@
+"use strict";
+
+const { Schemas } = ChromeUtils.importESModule(
+ "resource://gre/modules/Schemas.sys.mjs"
+);
+
+/**
+ * If this test fails, likely nsIClassifiedChannel has added or changed a
+ * CLASSIFIED_* flag. Those changes must be in sync with
+ * ChannelWrapper.webidl/cpp and the web_request.json schema file.
+ */
+add_task(async function test_webrequest_url_classification_enum() {
+ // The startupCache is removed whenever the buildid changes by code that runs
+ // during Firefox startup but not during xpcshell startup, remove it by hand
+ // before running this test to avoid failures with --conditioned-profile
+ let file = PathUtils.join(
+ Services.dirsvc.get("ProfLD", Ci.nsIFile).path,
+ "startupCache",
+ "webext.sc.lz4"
+ );
+ await IOUtils.remove(file, { ignoreAbsent: true });
+
+ // use normalizeManifest to get the schema loaded.
+ await ExtensionTestUtils.normalizeManifest({ permissions: ["webRequest"] });
+
+ let ns = Schemas.getNamespace("webRequest");
+ let schema_enum = ns.get("UrlClassificationFlags").enumeration;
+ ok(
+ !!schema_enum.length,
+ `UrlClassificationFlags: ${JSON.stringify(schema_enum)}`
+ );
+
+ let prefix = /^(?:CLASSIFIED_)/;
+ let entries = 0;
+ for (let c of Object.keys(Ci.nsIClassifiedChannel).filter(name =>
+ prefix.test(name)
+ )) {
+ let entry = c.replace(prefix, "").toLowerCase();
+ if (!entry.startsWith("socialtracking")) {
+ ok(schema_enum.includes(entry), `schema ${entry} is in IDL`);
+ entries++;
+ }
+ }
+ equal(schema_enum.length, entries, "same number of entries");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js
new file mode 100644
index 0000000000..9710aa5990
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js
@@ -0,0 +1,41 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_userContextId_webrequest() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ async details => {
+ browser.test.assertEq(
+ details.cookieStoreId,
+ "firefox-container-2",
+ "cookieStoreId is set"
+ );
+ browser.test.notifyPass("webRequest");
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+ },
+ });
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy",
+ { userContextId: 2 }
+ );
+ await extension.awaitFinish("webRequest");
+
+ await extension.unload();
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js
new file mode 100644
index 0000000000..35b713e59b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js
@@ -0,0 +1,95 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+add_task(async function test_webRequest_viewsource() {
+ function background(serverPort) {
+ browser.proxy.onRequest.addListener(
+ details => {
+ if (details.url === `http://example.com:${serverPort}/dummy`) {
+ browser.test.assertTrue(
+ true,
+ "viewsource protocol worked in proxy request"
+ );
+ browser.test.sendMessage("proxied");
+ }
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(
+ `http://example.com:${serverPort}/redirect`,
+ details.url,
+ "viewsource protocol worked in webRequest"
+ );
+ browser.test.sendMessage("viewed");
+ return { redirectUrl: `http://example.com:${serverPort}/dummy` };
+ },
+ { urls: ["http://example.com/redirect"] },
+ ["blocking"]
+ );
+
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(
+ `http://example.com:${serverPort}/dummy`,
+ details.url,
+ "viewsource protocol worked in webRequest"
+ );
+ browser.test.sendMessage("redirected");
+ return { cancel: true };
+ },
+ { urls: ["http://example.com/dummy"] },
+ ["blocking"]
+ );
+
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ // If cancel fails we get onCompleted.
+ browser.test.fail("onCompleted received");
+ },
+ { urls: ["http://example.com/dummy"] }
+ );
+
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.assertEq(
+ details.error,
+ "NS_ERROR_ABORT",
+ "request cancelled"
+ );
+ browser.test.sendMessage("cancelled");
+ },
+ { urls: ["http://example.com/dummy"] }
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background: `(${background})(${server.identity.primaryPort})`,
+ });
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `view-source:http://example.com:${server.identity.primaryPort}/redirect`
+ );
+
+ await Promise.all([
+ extension.awaitMessage("proxied"),
+ extension.awaitMessage("viewed"),
+ extension.awaitMessage("redirected"),
+ extension.awaitMessage("cancelled"),
+ ]);
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js
new file mode 100644
index 0000000000..c624de4280
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js
@@ -0,0 +1,144 @@
+"use strict";
+
+const server = createHttpServer();
+const BASE_URL = `http://127.0.0.1:${server.identity.primaryPort}`;
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+server.registerPathHandler("/redir", (request, response) => {
+ response.setStatusLine(request.httpVersion, 303, "See Other");
+ response.setHeader("Location", `${BASE_URL}/dummy`);
+});
+
+async function testViewSource(viewSourceUrl) {
+ function background(BASE_URL) {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(`${BASE_URL}/dummy`, details.url, "expected URL");
+ browser.test.assertEq("main_frame", details.type, "details.type");
+
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ filter.onstart = () => {
+ filter.write(new TextEncoder().encode("PREFIX_"));
+ };
+ filter.ondata = event => {
+ filter.write(event.data);
+ };
+ filter.onstop = () => {
+ filter.write(new TextEncoder().encode("_SUFFIX"));
+ filter.disconnect();
+ browser.test.notifyPass("filter_end");
+ };
+ filter.onerror = () => {
+ browser.test.fail(`Unexpected error: ${filter.error}`);
+ browser.test.notifyFail("filter_end");
+ };
+ },
+ { urls: ["*://*/dummy"] },
+ ["blocking"]
+ );
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(`${BASE_URL}/redir`, details.url, "Got redirect");
+
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ filter.onstop = () => {
+ filter.disconnect();
+ browser.test.fail("Unexpected onstop for redirect");
+ browser.test.sendMessage("redirect_done");
+ };
+ filter.onerror = () => {
+ browser.test.assertEq(
+ // TODO bug 1683862: must be "Channel redirected", but it is not
+ // because document requests are handled differently compared to
+ // other requests, see the comment at the top of
+ // test_ext_webRequest_redirect_StreamFilter.js.
+ "Invalid request ID",
+ filter.error,
+ "Expected error in filter.onerror"
+ );
+ browser.test.sendMessage("redirect_done");
+ };
+ },
+ { urls: ["*://*/redir"] },
+ ["blocking"]
+ );
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "*://*/*"],
+ },
+ background: `(${background})(${JSON.stringify(BASE_URL)})`,
+ });
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(viewSourceUrl);
+ if (viewSourceUrl.includes("/redir")) {
+ info("Awaiting observed completion of redirection request");
+ await extension.awaitMessage("redirect_done");
+ }
+ info("Awaiting completion of StreamFilter on request");
+ await extension.awaitFinish("filter_end");
+ let contentText = await contentPage.spawn([], () => {
+ return this.content.document.body.textContent;
+ });
+ equal(contentText, "PREFIX_ok_SUFFIX", "view-source response body");
+ await contentPage.close();
+ await extension.unload();
+}
+
+add_task(async function test_StreamFilter_viewsource() {
+ await testViewSource(`view-source:${BASE_URL}/dummy`);
+});
+
+add_task(async function test_StreamFilter_viewsource_redirect_target() {
+ await testViewSource(`view-source:${BASE_URL}/redir`);
+});
+
+// Sanity check: nothing bad happens if the underlying response is aborted.
+add_task(async function test_StreamFilter_viewsource_cancel() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "*://*/*"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ filter.onstart = () => {
+ filter.disconnect();
+ browser.test.fail("Unexpected filter.onstart");
+ browser.test.notifyFail("filter_end");
+ };
+ filter.onerror = () => {
+ browser.test.assertEq("Invalid request ID", filter.error, "Error?");
+ browser.test.notifyPass("filter_end");
+ };
+ },
+ { urls: ["*://*/dummy"] },
+ ["blocking"]
+ );
+ browser.webRequest.onHeadersReceived.addListener(
+ () => {
+ browser.test.log("Intentionally canceling view-source request");
+ return { cancel: true };
+ },
+ { urls: ["*://*/dummy"] },
+ ["blocking"]
+ );
+ },
+ });
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/dummy`
+ );
+ await extension.awaitFinish("filter_end");
+ let contentText = await contentPage.spawn([], () => {
+ return this.content.document.body.textContent;
+ });
+ equal(contentText, "", "view-source request should have been canceled");
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js
new file mode 100644
index 0000000000..7e34d2b0b3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js
@@ -0,0 +1,55 @@
+"use strict";
+
+const HOSTS = new Set(["example.com"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+add_task(async function test_webSocket() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(
+ "ws:",
+ new URL(details.url).protocol,
+ "ws protocol worked"
+ );
+ browser.test.notifyPass("websocket");
+ },
+ { urls: ["ws://example.com/*"] },
+ ["blocking"]
+ );
+
+ browser.test.onMessage.addListener(msg => {
+ let ws = new WebSocket("ws://example.com/dummy");
+ ws.onopen = e => {
+ ws.send("data");
+ };
+ ws.onclose = e => {};
+ ws.onerror = e => {};
+ ws.onmessage = e => {
+ ws.close();
+ };
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ extension.sendMessage("go");
+ await extension.awaitFinish("websocket");
+
+ // Wait until the next tick so that listener responses are processed
+ // before we unload.
+ await new Promise(executeSoon);
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webSocket.js b/toolkit/components/extensions/test/xpcshell/test_ext_webSocket.js
new file mode 100644
index 0000000000..d5aab3c7f6
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webSocket.js
@@ -0,0 +1,162 @@
+"use strict";
+
+const HOSTS = new Set(["example.com"]);
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+const BASE_URL = `http://example.com`;
+const pageURL = `${BASE_URL}/plain.html`;
+
+server.registerPathHandler("/plain.html", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html");
+ response.setHeader("Content-Security-Policy", "upgrade-insecure-requests;");
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+async function testWebSocketInFrameUpgraded() {
+ const frame = document.createElement("iframe");
+ frame.src = browser.runtime.getURL("frame.html");
+ document.documentElement.appendChild(frame);
+}
+
+// testIframe = true: open WebSocket from iframe (original test case).
+// testIframe = false: open WebSocket from content script.
+async function test_webSocket({
+ manifest_version,
+ useIframe,
+ content_security_policy,
+ expectUpgrade,
+}) {
+ let web_accessible_resources =
+ manifest_version == 2
+ ? ["frame.html"]
+ : [{ resources: ["frame.html"], matches: ["*://example.com/*"] }];
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ permissions: ["webRequest", "webRequestBlocking"],
+ host_permissions: ["<all_urls>"],
+ granted_host_permissions: true,
+ web_accessible_resources,
+ content_security_policy,
+ content_scripts: [
+ {
+ matches: ["http://*/plain.html"],
+ run_at: "document_idle",
+ js: [useIframe ? "content_script.js" : "load_WebSocket.js"],
+ },
+ ],
+ },
+ temporarilyInstalled: true,
+ background() {
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ let header = details.requestHeaders.find(h => h.name === "Origin");
+ browser.test.sendMessage("ws_request", {
+ ws_scheme: new URL(details.url).protocol,
+ originHeader: header?.value,
+ });
+ },
+ { urls: ["wss://example.com/*", "ws://example.com/*"] },
+ ["requestHeaders", "blocking"]
+ );
+ },
+ files: {
+ "frame.html": `
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <script src="load_WebSocket.js"></script>
+ </head>
+ <body>
+ </body>
+</html>
+ `,
+ "load_WebSocket.js": `new WebSocket("ws://example.com/ws_dummy");`,
+ "content_script.js": `
+ (${testWebSocketInFrameUpgraded})()
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
+ let { ws_scheme, originHeader } = await extension.awaitMessage("ws_request");
+
+ if (expectUpgrade) {
+ Assert.equal(ws_scheme, "wss:", "ws:-request should have been upgraded");
+ } else {
+ Assert.equal(ws_scheme, "ws:", "ws:-request should not have been upgraded");
+ }
+
+ if (useIframe) {
+ Assert.equal(
+ originHeader,
+ `moz-extension://${extension.uuid}`,
+ "Origin header of WebSocket request from extension page"
+ );
+ } else {
+ Assert.equal(
+ originHeader,
+ manifest_version == 2 ? "null" : "http://example.com",
+ "Origin header of WebSocket request from content script"
+ );
+ }
+ await contentPage.close();
+ await extension.unload();
+}
+
+// Page CSP does not affect extension iframes.
+add_task(async function test_webSocket_upgrade_iframe_mv2() {
+ await test_webSocket({
+ manifest_version: 2,
+ useIframe: true,
+ expectUpgrade: false,
+ });
+});
+
+// Page CSP does not affect extension iframes, however upgrade-insecure-requests causes this
+// request to be upgraded in the iframe.
+add_task(async function test_webSocket_upgrade_iframe_mv3() {
+ await test_webSocket({
+ manifest_version: 3,
+ useIframe: true,
+ expectUpgrade: true,
+ });
+});
+
+// Test that removing upgrade-insecure-requests allows http request in the iframe.
+add_task(async function test_webSocket_noupgrade_iframe_mv3() {
+ let content_security_policy = {
+ extension_pages: `script-src 'self'`,
+ };
+ await test_webSocket({
+ manifest_version: 3,
+ content_security_policy,
+ useIframe: true,
+ expectUpgrade: false,
+ });
+});
+
+// Page CSP does not affect MV2 in the content script.
+add_task(async function test_webSocket_upgrade_in_contentscript_mv2() {
+ await test_webSocket({
+ manifest_version: 2,
+ useIframe: false,
+ expectUpgrade: false,
+ });
+});
+
+// Page CSP affects MV3 in the content script.
+add_task(async function test_webSocket_upgrade_in_contentscript_mv3() {
+ await test_webSocket({
+ manifest_version: 3,
+ useIframe: false,
+ expectUpgrade: true,
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js
new file mode 100644
index 0000000000..0b34dd8127
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js
@@ -0,0 +1,148 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com", "example.org"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+let image = atob(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" +
+ "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="
+);
+const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte =>
+ byte.charCodeAt(0)
+).buffer;
+
+async function testImageLoading(src, expectedAction) {
+ let imageLoadingPromise = new Promise((resolve, reject) => {
+ let cleanupListeners;
+ let testImage = document.createElement("img");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ testImage.wrappedJSObject.setAttribute("src", src);
+
+ let loadListener = () => {
+ cleanupListeners();
+ resolve(expectedAction === "loaded");
+ };
+
+ let errorListener = () => {
+ cleanupListeners();
+ resolve(expectedAction === "blocked");
+ };
+
+ cleanupListeners = () => {
+ testImage.removeEventListener("load", loadListener);
+ testImage.removeEventListener("error", errorListener);
+ };
+
+ testImage.addEventListener("load", loadListener);
+ testImage.addEventListener("error", errorListener);
+
+ document.body.appendChild(testImage);
+ });
+
+ let success = await imageLoadingPromise;
+ browser.runtime.sendMessage({
+ name: "image-loading",
+ expectedAction,
+ success,
+ });
+}
+
+add_task(async function test_web_accessible_resources_csp() {
+ function background() {
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ if (msg.name === "image-loading") {
+ browser.test.assertTrue(msg.success, `Image was ${msg.expectedAction}`);
+ browser.test.sendMessage(`image-${msg.expectedAction}`);
+ } else {
+ browser.test.sendMessage(msg);
+ }
+ });
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ function content() {
+ window.addEventListener("message", function rcv(event) {
+ browser.runtime.sendMessage("script-ran");
+ window.removeEventListener("message", rcv);
+ });
+
+ testImageLoading(browser.runtime.getURL("image.png"), "loaded");
+
+ let testScriptElement = document.createElement("script");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ testScriptElement.wrappedJSObject.setAttribute(
+ "src",
+ browser.runtime.getURL("test_script.js")
+ );
+ document.head.appendChild(testScriptElement);
+ browser.runtime.sendMessage("script-loaded");
+ }
+
+ function testScript() {
+ window.postMessage("test-script-loaded", "*");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/*/file_csp.html"],
+ run_at: "document_end",
+ js: ["content_script_helper.js", "content_script.js"],
+ },
+ ],
+ web_accessible_resources: ["image.png", "test_script.js"],
+ },
+ background,
+ files: {
+ "content_script_helper.js": `${testImageLoading}`,
+ "content_script.js": content,
+ "test_script.js": testScript,
+ "image.png": IMAGE_ARRAYBUFFER,
+ },
+ });
+
+ await Promise.all([
+ extension.startup(),
+ extension.awaitMessage("background-ready"),
+ ]);
+
+ let page = await ExtensionTestUtils.loadContentPage(
+ `http://example.com/data/file_sample.html`
+ );
+ await page.legacySpawn(null, () => {
+ this.obs = {
+ events: [],
+ observe(subject, topic, data) {
+ this.events.push(subject.QueryInterface(Ci.nsIURI).spec);
+ },
+ done() {
+ Services.obs.removeObserver(this, "csp-on-violate-policy");
+ return this.events;
+ },
+ };
+ Services.obs.addObserver(this.obs, "csp-on-violate-policy");
+ content.location.href = "http://example.com/data/file_csp.html";
+ });
+
+ await Promise.all([
+ extension.awaitMessage("image-loaded"),
+ extension.awaitMessage("script-loaded"),
+ extension.awaitMessage("script-ran"),
+ ]);
+
+ let events = await page.legacySpawn(null, () => this.obs.done());
+ equal(events.length, 2, "Two items were rejected by CSP");
+ for (let url of events) {
+ ok(
+ url.includes("file_image_bad.png") || url.includes("file_script_bad.js"),
+ `Expected file: ${url} rejected by CSP`
+ );
+ }
+
+ await page.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources_matches.js b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources_matches.js
new file mode 100644
index 0000000000..1cd5074780
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources_matches.js
@@ -0,0 +1,546 @@
+"use strict";
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+const server = createHttpServer({ hosts: ["example.com", "example.org"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+let image = atob(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" +
+ "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="
+);
+const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte =>
+ byte.charCodeAt(0)
+).buffer;
+
+add_task(async function test_web_accessible_resources_matching() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ web_accessible_resources: [
+ {
+ resources: ["/accessible.html"],
+ },
+ ],
+ },
+ });
+
+ await Assert.rejects(
+ extension.startup(),
+ /web_accessible_resources requires one of "matches" or "extension_ids"/,
+ "web_accessible_resources object format incorrect"
+ );
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ web_accessible_resources: [
+ {
+ resources: ["/accessible.html"],
+ matches: ["http://example.com/data/*"],
+ },
+ ],
+ },
+ });
+
+ await extension.startup();
+ ok(true, "web_accessible_resources with matches loads");
+ await extension.unload();
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ web_accessible_resources: [
+ {
+ resources: ["/accessible.html"],
+ extension_ids: ["foo@mochitest"],
+ },
+ ],
+ },
+ });
+
+ await extension.startup();
+ ok(true, "web_accessible_resources with extensions loads");
+ await extension.unload();
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ web_accessible_resources: [
+ {
+ resources: ["/accessible.html"],
+ matches: ["http://example.com/data/*"],
+ extension_ids: ["foo@mochitest"],
+ },
+ ],
+ },
+ });
+
+ await extension.startup();
+ ok(true, "web_accessible_resources with matches and extensions loads");
+ await extension.unload();
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ web_accessible_resources: [
+ {
+ resources: ["/accessible.html"],
+ extension_ids: [],
+ },
+ ],
+ },
+ });
+
+ await extension.startup();
+ ok(true, "web_accessible_resources with empty extensions loads");
+ await extension.unload();
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ web_accessible_resources: [
+ {
+ resources: ["/accessible.html"],
+ matches: ["http://example.com/data/*"],
+ extension_ids: [],
+ },
+ ],
+ },
+ });
+
+ await extension.startup();
+ ok(true, "web_accessible_resources with matches and empty extensions loads");
+ await extension.unload();
+});
+
+add_task(async function test_web_accessible_resources() {
+ async function contentScript() {
+ let canLoad = window.location.href.startsWith("http://example.com");
+ let urls = [
+ {
+ name: "iframe",
+ path: "accessible.html",
+ shouldLoad: canLoad,
+ },
+ {
+ name: "iframe",
+ path: "inaccessible.html",
+ shouldLoad: false,
+ },
+ {
+ name: "img",
+ path: "image.png",
+ shouldLoad: true,
+ },
+ {
+ name: "script",
+ path: "script.js",
+ shouldLoad: canLoad,
+ },
+ ];
+
+ function test_element_src(name, url) {
+ return new Promise(resolve => {
+ let elem = document.createElement(name);
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ elem.wrappedJSObject.setAttribute("src", url);
+ elem.addEventListener(
+ "load",
+ () => {
+ resolve(true);
+ },
+ { once: true }
+ );
+ elem.addEventListener(
+ "error",
+ () => {
+ resolve(false);
+ },
+ { once: true }
+ );
+ document.body.appendChild(elem);
+ });
+ }
+ for (let test of urls) {
+ let loaded = await test_element_src(
+ test.name,
+ browser.runtime.getURL(test.path)
+ );
+ browser.test.assertEq(
+ loaded,
+ test.shouldLoad,
+ `resource loaded ${test.path} in ${window.location.href}`
+ );
+ }
+ browser.test.sendMessage("complete");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/*", "http://example.org/data/*"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+ host_permissions: ["http://example.com/*", "http://example.org/*"],
+ granted_host_permissions: true,
+
+ web_accessible_resources: [
+ {
+ resources: ["/accessible.html", "/script.js"],
+ matches: ["http://example.com/data/*"],
+ },
+ {
+ resources: ["/image.png"],
+ matches: ["<all_urls>"],
+ },
+ ],
+ },
+ temporarilyInstalled: true,
+
+ files: {
+ "content_script.js": contentScript,
+
+ "accessible.html": `<html><head>
+ <meta charset="utf-8">
+ </head></html>`,
+
+ "inaccessible.html": `<html><head>
+ <meta charset="utf-8">
+ </head></html>`,
+
+ "image.png": IMAGE_ARRAYBUFFER,
+ "script.js": () => {
+ // empty script
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let page = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/"
+ );
+
+ await extension.awaitMessage("complete");
+ await page.close();
+
+ // None of the test resources are loadable in example.org
+ page = await ExtensionTestUtils.loadContentPage("http://example.org/data/");
+
+ await extension.awaitMessage("complete");
+
+ await page.close();
+ await extension.unload();
+});
+
+async function pageScript() {
+ function test_element_src(data) {
+ return new Promise(resolve => {
+ let elem = document.createElement(data.elem);
+ let elemContext =
+ data.content_context && elem.wrappedJSObject
+ ? elem.wrappedJSObject
+ : elem;
+ elemContext.setAttribute("src", data.url);
+ elem.addEventListener(
+ "load",
+ () => {
+ browser.test.log(`got load event for ${data.url}`);
+ resolve(true);
+ },
+ { once: true }
+ );
+ elem.addEventListener(
+ "error",
+ () => {
+ browser.test.log(`got error event for ${data.url}`);
+ resolve(false);
+ },
+ { once: true }
+ );
+ document.body.appendChild(elem);
+ });
+ }
+ browser.test.onMessage.addListener(async msg => {
+ browser.test.log(`testing ${JSON.stringify(msg)}`);
+ let loaded = await test_element_src(msg);
+ browser.test.assertEq(loaded, msg.shouldLoad, `${msg.name} loaded`);
+ browser.test.sendMessage("web-accessible-resources");
+ });
+ browser.test.sendMessage("page-loaded");
+}
+
+add_task(async function test_web_accessible_resources_extensions() {
+ let other = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "other@mochitest" } },
+ },
+ files: {
+ "page.js": pageScript,
+
+ "page.html": `<html><head>
+ <meta charset="utf-8">
+ <script src="page.js"></script>
+ </head></html>`,
+ },
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ browser_specific_settings: { gecko: { id: "this@mochitest" } },
+ web_accessible_resources: [
+ {
+ resources: ["/image.png"],
+ extension_ids: ["other@mochitest"],
+ },
+ ],
+ },
+
+ files: {
+ "image.png": IMAGE_ARRAYBUFFER,
+ "inaccessible.png": IMAGE_ARRAYBUFFER,
+ "page.js": pageScript,
+
+ "page.html": `<html><head>
+ <meta charset="utf-8">
+ <script src="page.js"></script>
+ </head></html>`,
+ },
+ });
+
+ await extension.startup();
+ let extensionUrl = `moz-extension://${extension.uuid}/`;
+
+ await other.startup();
+ let pageUrl = `moz-extension://${other.uuid}/page.html`;
+
+ let page = await ExtensionTestUtils.loadContentPage(pageUrl);
+ await other.awaitMessage("page-loaded");
+
+ other.sendMessage({
+ name: "accessible resource",
+ elem: "img",
+ url: `${extensionUrl}image.png`,
+ shouldLoad: true,
+ });
+ await other.awaitMessage("web-accessible-resources");
+
+ other.sendMessage({
+ name: "inaccessible resource",
+ elem: "img",
+ url: `${extensionUrl}inaccessible.png`,
+ shouldLoad: false,
+ });
+ await other.awaitMessage("web-accessible-resources");
+
+ await page.close();
+
+ // test that the extension may load it's own web accessible resource
+ page = await ExtensionTestUtils.loadContentPage(`${extensionUrl}page.html`);
+ await extension.awaitMessage("page-loaded");
+
+ extension.sendMessage({
+ name: "accessible resource",
+ elem: "img",
+ url: `${extensionUrl}image.png`,
+ shouldLoad: true,
+ });
+ await extension.awaitMessage("web-accessible-resources");
+
+ await page.close();
+ await extension.unload();
+ await other.unload();
+});
+
+// test that a web page not in matches cannot load the resource
+add_task(async function test_web_accessible_resources_inaccessible() {
+ let extension = ExtensionTestUtils.loadExtension({
+ temporarilyInstalled: true,
+ manifest: {
+ manifest_version: 3,
+ browser_specific_settings: { gecko: { id: "web@mochitest" } },
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/*"],
+ js: ["page.js"],
+ run_at: "document_idle",
+ },
+ ],
+ web_accessible_resources: [
+ {
+ resources: ["/image.png"],
+ extension_ids: ["some_other_ext@mochitest"],
+ },
+ ],
+ host_permissions: ["*://example.com/*"],
+ granted_host_permissions: true,
+ },
+
+ files: {
+ "image.png": IMAGE_ARRAYBUFFER,
+ "page.js": pageScript,
+
+ "page.html": `<html><head>
+ <meta charset="utf-8">
+ <script src="page.js"></script>
+ </head></html>`,
+ },
+ });
+
+ await extension.startup();
+ let extensionUrl = `moz-extension://${extension.uuid}/`;
+ let page = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/"
+ );
+ await extension.awaitMessage("page-loaded");
+
+ extension.sendMessage({
+ name: "cannot access resource",
+ elem: "img",
+ url: `${extensionUrl}image.png`,
+ content_context: true,
+ shouldLoad: false,
+ });
+ await extension.awaitMessage("web-accessible-resources");
+
+ await page.close();
+ await extension.unload();
+});
+
+add_task(async function test_web_accessible_resources_empty_extension_ids() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ web_accessible_resources: [
+ {
+ resources: ["/file.txt"],
+ matches: ["http://example.com/data/*"],
+ extension_ids: [],
+ },
+ ],
+ },
+
+ files: {
+ "file.txt": "some content",
+ },
+ });
+ let secondExtension = ExtensionTestUtils.loadExtension({
+ files: {
+ "page.html": "",
+ },
+ });
+
+ await extension.startup();
+ await secondExtension.startup();
+
+ const fileURL = extension.extension.baseURI.resolve("file.txt");
+ Assert.equal(
+ await ExtensionTestUtils.fetch("http://example.com/data/", fileURL),
+ "some content",
+ "expected access to the extension's resource"
+ );
+
+ await Assert.rejects(
+ ExtensionTestUtils.fetch(
+ secondExtension.extension.baseURI.resolve("page.html"),
+ fileURL
+ ),
+ e => e?.message === "NetworkError when attempting to fetch resource.",
+ "other extension should not be able to fetch when extension_ids is empty"
+ );
+
+ await extension.unload();
+ await secondExtension.unload();
+});
+
+add_task(async function test_web_accessible_resources_empty_array() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ web_accessible_resources: [],
+ },
+ });
+ await extension.startup();
+ ok(true, "empty web_accessible_resources loads");
+ await extension.unload();
+});
+
+add_task(async function test_web_accessible_resources_empty_resources() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ web_accessible_resources: [{ resources: [], matches: ["*://*/*"] }],
+ },
+ });
+ await extension.startup();
+ ok(true, "empty web_accessible_resources[0].resources loads");
+ await extension.unload();
+});
+
+add_task(async function test_web_accessible_resources_empty_everything() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ web_accessible_resources: [
+ { resources: [], matches: [], extension_ids: [] },
+ ],
+ },
+ });
+ await extension.startup();
+ ok(true, "empty resources, matches & extension_ids loads");
+ await extension.unload();
+});
+
+add_task(async function test_web_accessible_resources_empty_matches() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ web_accessible_resources: [{ resources: ["file.txt"], matches: [] }],
+ },
+ files: {
+ "file.txt": "some content",
+ },
+ });
+ await extension.startup();
+ ok(true, "empty web_accessible_resources[0].matches loads");
+
+ const fileURL = extension.extension.baseURI.resolve("file.txt");
+ await Assert.rejects(
+ ExtensionTestUtils.fetch("http://example.com", fileURL),
+ e => e?.message === "NetworkError when attempting to fetch resource.",
+ "empty matches[] = not web-accessible"
+ );
+ await extension.unload();
+});
+
+add_task(async function test_web_accessible_resources_unknown_property() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ web_accessible_resources: [{ resources: [], matches: [], idk: null }],
+ },
+ });
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ {
+ message:
+ /Reading manifest: Warning processing web_accessible_resources.0.idk: An unexpected property was found in the WebExtension manifest./,
+ },
+ ],
+ });
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js
new file mode 100644
index 0000000000..0728946817
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js
@@ -0,0 +1,72 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_xhr_capabilities() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", browser.runtime.getURL("bad.xml"));
+
+ browser.test.sendMessage("result", {
+ name: "Background script XHRs should not be privileged",
+ result: xhr.channel === undefined,
+ });
+
+ xhr.onload = () => {
+ browser.test.sendMessage("result", {
+ name: "Background script XHRs should not yield <parsererrors>",
+ result: xhr.responseXML === null,
+ });
+ };
+ xhr.send();
+ },
+
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ web_accessible_resources: ["bad.xml"],
+ },
+
+ files: {
+ "bad.xml": "<xml",
+ "content_script.js"() {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", browser.runtime.getURL("bad.xml"));
+
+ browser.test.sendMessage("result", {
+ name: "Content script XHRs should not be privileged",
+ result: xhr.channel === undefined,
+ });
+
+ xhr.onload = () => {
+ browser.test.sendMessage("result", {
+ name: "Content script XHRs should not yield <parsererrors>",
+ result: xhr.responseXML === null,
+ });
+ };
+ xhr.send();
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ // We expect four test results from the content/background scripts.
+ for (let i = 0; i < 4; ++i) {
+ let result = await extension.awaitMessage("result");
+ ok(result.result, result.name);
+ }
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_xhr_cors.js b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_cors.js
new file mode 100644
index 0000000000..983fe1c542
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_cors.js
@@ -0,0 +1,223 @@
+"use strict";
+
+// The purpose of this test is to show that the XMLHttpRequest API behaves
+// similarly in MV2 and MV3, except for intentional differences related to
+// permission handling.
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+const server = createHttpServer({
+ hosts: ["example.com", "example.net", "example.org"],
+});
+server.registerPathHandler("/dummy", (req, res) => {
+ res.setStatusLine(req.httpVersion, 200, "OK");
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+
+ // A very strict CSP.
+ res.setHeader(
+ "Content-Security-Policy",
+ "default-src; script-src 'nonce-kindasecret'; connect-src http:"
+ );
+
+ res.write(
+ `<script id="id_of_some_element" nonce="kindasecret">
+ // Clobber XMLHttpRequest API to allow us to verify that the page's value
+ // for it does not affect the XMLHttpRequest API in the content script.
+ window.XMLHttpRequest = "This is not XMLHttpRequest";
+ </script>
+ `
+ );
+});
+server.registerPathHandler("/dummy.json", (req, res) => {
+ res.write(`{"mykey": "kvalue"}`);
+});
+server.registerPathHandler("/nocors", (req, res) => {
+ res.write("no cors");
+});
+server.registerPathHandler("/cors-enabled", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "http://example.com");
+ res.write("cors_response");
+});
+server.registerPathHandler("/return-origin", (req, res) => {
+ res.setHeader("Content-Type", "text/plain");
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.setHeader("Access-Control-Allow-Methods", "*");
+ res.write(req.hasHeader("Origin") ? req.getHeader("Origin") : "undefined");
+});
+
+// We just need to test XHR; fetch is already covered by test_ext_secfetch.js.
+async function test_xhr({ manifest_version }) {
+ async function contentScript(manifest_version) {
+ function runXHR(url, extraXHRProps, method = "GET") {
+ return new Promise(resolve => {
+ let x = new XMLHttpRequest();
+ x.open(method, url);
+ Object.assign(x, extraXHRProps);
+ x.onloadend = () => resolve(x);
+ x.send();
+ });
+ }
+ async function checkXHR({
+ description,
+ url,
+ extraXHRProps,
+ method,
+ expected,
+ }) {
+ let { status, response } = expected;
+ let x = await runXHR(url, extraXHRProps, method);
+ browser.test.assertEq(status, x.status, `${description} - status`);
+ browser.test.assertEq(response, x.response, `${description} - body`);
+ }
+
+ await checkXHR({
+ description: "Same-origin",
+ url: "http://example.com/nocors",
+ expected: { status: 200, response: "no cors" },
+ });
+
+ await checkXHR({
+ description: "Cross-origin without CORS",
+ url: "http://example.org/nocors",
+ expected: { status: 0, response: "" },
+ });
+
+ await checkXHR({
+ description: "Cross-origin with CORS",
+ url: "http://example.org/cors-enabled",
+ expected:
+ manifest_version === 2
+ ? // Bug 1605197: MV2 cannot fall back to CORS.
+ { status: 0, response: "" }
+ : { status: 200, response: "cors_response" },
+ });
+
+ // MV2 allowed cross-origin requests in content scripts with host
+ // permissions, but MV3 does not.
+ await checkXHR({
+ description: "Cross-origin without CORS, with permission",
+ url: "http://example.net/nocors",
+ expected:
+ manifest_version === 2
+ ? { status: 200, response: "no cors" }
+ : { status: 0, response: "" },
+ });
+
+ await checkXHR({
+ description: "Cross-origin with CORS (and permission)",
+ url: "http://example.net/cors-enabled",
+ expected: { status: 200, response: "cors_response" },
+ });
+
+ // MV2 has a XMLHttpRequest instance specific to the sandbox.
+ // MV3 uses the page's XMLHttpRequest and currently enforces the page's CSP.
+ // TODO bug 1766813: Enforce content script CSP instead.
+ await checkXHR({
+ description: "data:-URL while page blocks data: via CSP",
+ url: "data:,data-url",
+ expected:
+ // Should be "data-url" in MV3 too.
+ manifest_version === 2
+ ? { status: 200, response: "data-url" }
+ : { status: 0, response: "" },
+ });
+
+ {
+ let x = await runXHR("http://example.com/dummy.json", {
+ responseType: "json",
+ });
+ browser.test.assertTrue(x.response instanceof Object, "is JSON object");
+ browser.test.assertEq(x.response.mykey, "kvalue", "can read parsed JSON");
+ }
+
+ {
+ let x = await runXHR("http://example.com/dummy", {
+ responseType: "document",
+ });
+ browser.test.assertTrue(HTMLDocument.isInstance(x.response), "is doc");
+ browser.test.assertTrue(
+ x.response.querySelector("#id_of_some_element"),
+ "got parsed document"
+ );
+ }
+
+ await checkXHR({
+ description: "Same-origin Origin header",
+ url: "http://example.com/return-origin",
+ expected: { status: 200, response: "undefined" },
+ });
+
+ await checkXHR({
+ description: "Same-origin POST Origin header",
+ url: "http://example.com/return-origin",
+ method: "POST",
+ expected:
+ manifest_version === 2
+ ? { status: 200, response: "undefined" }
+ : { status: 200, response: "http://example.com" },
+ });
+
+ await checkXHR({
+ description: "Cross-origin (CORS) Origin header",
+ url: "http://example.org/return-origin",
+ expected:
+ manifest_version === 2
+ ? // Bug 1605197: MV2 cannot fall back to CORS.
+ { status: 0, response: "" }
+ : { status: 200, response: "http://example.com" },
+ });
+
+ await checkXHR({
+ description: "Cross-origin (CORS) POST Origin header",
+ url: "http://example.org/return-origin",
+ method: "POST",
+ expected:
+ manifest_version === 2
+ ? // Bug 1605197: MV2 cannot fall back to CORS.
+ { status: 0, response: "" }
+ : { status: 200, response: "http://example.com" },
+ });
+
+ browser.test.sendMessage("done");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ temporarilyInstalled: true, // Needed for granted_host_permissions
+ manifest: {
+ manifest_version,
+ granted_host_permissions: true, // Test-only: grant permissions in MV3.
+ host_permissions: [
+ "http://example.net/",
+ // Work-around for bug 1766752.
+ "http://example.com/",
+ // "http://example.org/" is intentionally missing.
+ ],
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy"],
+ run_at: "document_end",
+ js: ["contentscript.js"],
+ },
+ ],
+ },
+ files: {
+ "contentscript.js": `(${contentScript})(${manifest_version})`,
+ },
+ });
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+ await extension.awaitMessage("done");
+ await contentPage.close();
+
+ await extension.unload();
+}
+
+add_task(async function test_XHR_MV2() {
+ await test_xhr({ manifest_version: 2 });
+});
+
+add_task(async function test_XHR_MV3() {
+ await test_xhr({ manifest_version: 3 });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migrate_kvstore_path.js b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migrate_kvstore_path.js
new file mode 100644
index 0000000000..5f1c73dd3b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migrate_kvstore_path.js
@@ -0,0 +1,234 @@
+"use strict";
+
+const {
+ ExtensionPermissions,
+ OLD_RKV_DIRNAME,
+ RKV_DIRNAME,
+ VERSION_KEY,
+ VERSION_VALUE,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+const { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+const { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+const { KeyValueService } = ChromeUtils.importESModule(
+ "resource://gre/modules/kvstore.sys.mjs"
+);
+
+add_setup(async () => {
+ // Bug 1646182: Force ExtensionPermissions to run in rkv mode, because this
+ // test does not make sense with the legacy method (which will be removed in
+ // the above bug).
+ ExtensionPermissions._useLegacyStorageBackend = false;
+ await ExtensionPermissions._uninit();
+});
+
+// NOTE: this test lives in its own test file to make sure it is isolated
+// from other tests that would be creating the kvstore instance and
+// would prevent this test to properly simulate the kvstore path migration.
+add_task(async function test_migrate_to_separate_kvstore_store_path() {
+ const ADDON_ID_01 = "test-addon-01@test-extension";
+ const ADDON_ID_02 = "test-addon-02@test-extension";
+ // This third test extension is only used as the one that should
+ // have some content scripts stored in ExtensionScriptingStore.
+ const ADDON_ID_03 = "test-addon-03@test-extension";
+
+ const oldStorePath = FileUtils.getDir("ProfD", [OLD_RKV_DIRNAME]).path;
+ const newStorePath = FileUtils.getDir("ProfD", [RKV_DIRNAME]).path;
+
+ // Verify that we are going to be using the expected backend, and that
+ // the rkv path migration is only enabled by default in Nightly builds.
+ info("Verify test environment match the expected pre-conditions");
+
+ const permsStore = ExtensionPermissions._getStore();
+ equal(
+ permsStore.constructor.name,
+ "PermissionStore",
+ "active ExtensionPermissions store should be an instance of PermissionStore"
+ );
+
+ equal(
+ permsStore._shouldMigrateFromOldKVStorePath,
+ AppConstants.NIGHTLY_BUILD,
+ "ExtensionPermissions rkv migration expected to be enabled by default only in Nightly"
+ );
+
+ info(
+ "Uninitialize ExtensionPermissions and make sure no existing kvstore dir"
+ );
+ await ExtensionPermissions._uninit({ recreateStore: false });
+ equal(
+ ExtensionPermissions._getStore(),
+ null,
+ "PermissionStore has been nullified"
+ );
+ await IOUtils.remove(oldStorePath, { ignoreAbsent: true, recursive: true });
+ await IOUtils.remove(newStorePath, { ignoreAbsent: true, recursive: true });
+
+ info("Create an existing kvstore dir on the old path");
+
+ // Populated the kvstore with some expected permissions.
+ const expectedPermsAddon01 = {
+ permissions: ["tabs"],
+ origins: ["http://*/*"],
+ };
+ const expectedPermsAddon02 = {
+ permissions: ["proxy"],
+ origins: ["https://*/*"],
+ };
+
+ const expectedScriptAddon01 = {
+ id: "script-addon-01",
+ allFrames: false,
+ matches: ["<all_urls>"],
+ js: ["/test-script-addon-01.js"],
+ persistAcrossSessions: true,
+ runAt: "document_end",
+ };
+
+ const expectedScriptAddon02 = {
+ id: "script-addon-02",
+ allFrames: false,
+ matches: ["<all_urls"],
+ css: ["/test-script-addon-02.css"],
+ persistAcrossSessions: true,
+ runAt: "document_start",
+ };
+
+ {
+ // Make sure the folder exists
+ await IOUtils.makeDirectory(oldStorePath, { ignoreExisting: true });
+ // Create a permission kvstore dir on the old file path.
+ const kvstore = await KeyValueService.getOrCreate(
+ oldStorePath,
+ "permissions"
+ );
+ await kvstore.writeMany([
+ ["_version", 1],
+ [`id-${ADDON_ID_01}`, JSON.stringify(expectedPermsAddon01)],
+ [`id-${ADDON_ID_02}`, JSON.stringify(expectedPermsAddon02)],
+ ]);
+ }
+
+ {
+ // Add also scripting kvstore data into the same temp dir path.
+ const kvstore = await KeyValueService.getOrCreate(
+ oldStorePath,
+ "scripting-contentScripts"
+ );
+ await kvstore.writeMany([
+ [
+ `${ADDON_ID_03}/${expectedScriptAddon01.id}`,
+ JSON.stringify(expectedScriptAddon01),
+ ],
+ [
+ `${ADDON_ID_03}/${expectedScriptAddon02.id}`,
+ JSON.stringify(expectedScriptAddon02),
+ ],
+ ]);
+ }
+
+ ok(
+ await IOUtils.exists(oldStorePath),
+ "Found kvstore dir for the old store path"
+ );
+ ok(
+ !(await IOUtils.exists(newStorePath)),
+ "Expect kvstore dir for the new store path to don't exist yet"
+ );
+
+ info("Re-initialize the ExtensionPermission store and assert migrated data");
+ await ExtensionPermissions._uninit({ recreateStore: true });
+
+ // Explicitly enable migration (needed to make sure we hit the migration code
+ // that is only enabled by default on Nightly).
+ if (!AppConstants.NIGHTLY_BUILD) {
+ info("Enable ExtensionPermissions rkv migration on non-nightly channel");
+ const newStoreInstance = ExtensionPermissions._getStore();
+ newStoreInstance._shouldMigrateFromOldKVStorePath = true;
+ }
+
+ const permsAddon01 = await ExtensionPermissions._get(ADDON_ID_01);
+ const permsAddon02 = await ExtensionPermissions._get(ADDON_ID_02);
+
+ Assert.deepEqual(
+ { permsAddon01, permsAddon02 },
+ {
+ permsAddon01: expectedPermsAddon01,
+ permsAddon02: expectedPermsAddon02,
+ },
+ "Got the expected permissions migrated to the new store file path"
+ );
+
+ await ExtensionPermissions._uninit({ recreateStore: false });
+
+ ok(
+ await IOUtils.exists(newStorePath),
+ "Found kvstore dir for the new store path"
+ );
+
+ {
+ const newKVStore = await KeyValueService.getOrCreate(
+ newStorePath,
+ "permissions"
+ );
+ Assert.equal(
+ await newKVStore.get(VERSION_KEY),
+ VERSION_VALUE,
+ "Got the expected value set on the kvstore _version key"
+ );
+ }
+
+ // kvstore internally caching behavior doesn't make it easy to make sure
+ // we would be hitting a failure if the ExtensionPermissions kvstore migration
+ // would be mistakenly removing the old kvstore dir as part of that migration,
+ // and so the test case is explicitly verifying that the directory does still
+ // exist and then it copies it into a new path to confirm that the expected
+ // data have been kept in the old kvstore dir.
+ ok(
+ await IOUtils.exists(oldStorePath),
+ "Found kvstore dir for the old store path"
+ );
+ const oldStoreCopiedPath = FileTestUtils.getTempFile("kvstore-dir").path;
+ await IOUtils.copy(oldStorePath, oldStoreCopiedPath, { recursive: true });
+
+ // Confirm that the content scripts have not been copied into
+ // the new kvstore path.
+ async function assertStoredContentScripts(storePath, expectedKeys) {
+ const kvstore = await KeyValueService.getOrCreate(
+ storePath,
+ "scripting-contentScripts"
+ );
+ const enumerator = await kvstore.enumerate();
+ const keys = [];
+ while (enumerator.hasMoreElements()) {
+ keys.push(enumerator.getNext().key);
+ }
+ Assert.deepEqual(
+ keys,
+ expectedKeys,
+ `Got the expected scripts in the kvstore path ${storePath}`
+ );
+ }
+
+ info(
+ "Verify that no content scripts are stored in the new kvstore dir reserved for permissions"
+ );
+ await assertStoredContentScripts(newStorePath, []);
+ info(
+ "Verify that existing content scripts have been not been removed old kvstore dir"
+ );
+ await assertStoredContentScripts(oldStoreCopiedPath, [
+ `${ADDON_ID_03}/${expectedScriptAddon01.id}`,
+ `${ADDON_ID_03}/${expectedScriptAddon02.id}`,
+ ]);
+
+ await ExtensionPermissions._uninit({ recreateStore: true });
+
+ await IOUtils.remove(newStorePath, { recursive: true });
+ await IOUtils.remove(oldStorePath, { recursive: true });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js
new file mode 100644
index 0000000000..aa05377e0e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js
@@ -0,0 +1,112 @@
+"use strict";
+
+const {
+ ExtensionPermissions,
+ OLD_JSON_FILENAME,
+ OLD_RKV_DIRNAME,
+ RKV_DIRNAME,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+const GOOD_JSON_FILE = {
+ "wikipedia@search.mozilla.org": {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+ },
+ "amazon@search.mozilla.org": {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+ },
+ "doh-rollout@mozilla.org": {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+ },
+};
+
+const BAD_JSON_FILE = {
+ "test@example.org": "what",
+};
+
+const BAD_FILE = "what is this { } {";
+
+const gOldJSONPath = FileUtils.getDir("ProfD", [OLD_JSON_FILENAME]).path;
+const gOldRkvPath = FileUtils.getDir("ProfD", [OLD_RKV_DIRNAME]).path;
+const gNewRkvPath = FileUtils.getDir("ProfD", [RKV_DIRNAME]).path;
+
+async function test_file(json, extensionIds, expected, fileDeleted) {
+ await ExtensionPermissions._resetVersion();
+ await ExtensionPermissions._uninit();
+
+ await IOUtils.writeUTF8(gOldJSONPath, json);
+
+ for (let extensionId of extensionIds) {
+ let permissions = await ExtensionPermissions.get(extensionId);
+ Assert.deepEqual(permissions, expected, "permissions match");
+ }
+
+ Assert.equal(
+ await IOUtils.exists(gOldJSONPath),
+ !fileDeleted,
+ "old file was deleted"
+ );
+
+ Assert.ok(
+ await IOUtils.exists(gNewRkvPath),
+ "found the store at the new rkv path"
+ );
+
+ Assert.ok(
+ !(await IOUtils.exists(gOldRkvPath)),
+ "expect old rkv path to not exist"
+ );
+}
+
+add_setup(async () => {
+ // Bug 1646182: Force ExtensionPermissions to run in rkv mode, because this
+ // test does not make sense with the legacy method (which will be removed in
+ // the above bug).
+ await ExtensionPermissions._uninit();
+});
+
+add_task(async function test_migrate_good_json() {
+ let expected = {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+ };
+
+ await test_file(
+ JSON.stringify(GOOD_JSON_FILE),
+ [
+ "wikipedia@search.mozilla.org",
+ "amazon@search.mozilla.org",
+ "doh-rollout@mozilla.org",
+ ],
+ expected,
+ /* fileDeleted */ true
+ );
+});
+
+add_task(async function test_migrate_bad_json() {
+ let expected = { permissions: [], origins: [] };
+
+ await test_file(
+ BAD_FILE,
+ ["test@example.org"],
+ expected,
+ /* fileDeleted */ false
+ );
+ await IOUtils.remove(gOldJSONPath);
+});
+
+add_task(async function test_migrate_bad_file() {
+ let expected = { permissions: [], origins: [] };
+
+ await test_file(
+ JSON.stringify(BAD_JSON_FILE),
+ ["test2@example.org"],
+ expected,
+ /* fileDeleted */ false
+ );
+ await IOUtils.remove(gOldJSONPath);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js b/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js
new file mode 100644
index 0000000000..32299fb04e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js
@@ -0,0 +1,171 @@
+"use strict";
+
+const { Schemas } = ChromeUtils.importESModule(
+ "resource://gre/modules/Schemas.sys.mjs"
+);
+
+const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json";
+
+const CATEGORY_EXTENSION_MODULES = "webextension-modules";
+const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas";
+const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts";
+
+const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon";
+const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content";
+const CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS = "webextension-scripts-devtools";
+
+let schemaURLs = new Set();
+schemaURLs.add("chrome://extensions/content/schemas/experiments.json");
+
+// Helper class used to load the API modules similarly to the apiManager
+// defined in ExtensionParent.jsm.
+class FakeAPIManager extends ExtensionCommon.SchemaAPIManager {
+ constructor(processType = "main") {
+ super(processType, Schemas);
+ this.initialized = false;
+ }
+
+ getModuleJSONURLs() {
+ return Array.from(
+ Services.catMan.enumerateCategory(CATEGORY_EXTENSION_MODULES),
+ ({ value }) => value
+ );
+ }
+
+ async lazyInit() {
+ if (this.initialized) {
+ return;
+ }
+
+ this.initialized = true;
+
+ let modulesPromise = this.loadModuleJSON(this.getModuleJSONURLs());
+
+ let scriptURLs = [];
+ for (let { value } of Services.catMan.enumerateCategory(
+ CATEGORY_EXTENSION_SCRIPTS
+ )) {
+ scriptURLs.push(value);
+ }
+
+ let scripts = await Promise.all(
+ scriptURLs.map(url => ChromeUtils.compileScript(url))
+ );
+
+ this.initModuleData(await modulesPromise);
+
+ this.initGlobal();
+ for (let script of scripts) {
+ script.executeInGlobal(this.global);
+ }
+
+ // Load order matters here. The base manifest defines types which are
+ // extended by other schemas, so needs to be loaded first.
+ await Schemas.load(BASE_SCHEMA).then(() => {
+ let promises = [];
+ for (let { value } of Services.catMan.enumerateCategory(
+ CATEGORY_EXTENSION_SCHEMAS
+ )) {
+ promises.push(Schemas.load(value));
+ }
+ for (let [url, { content }] of this.schemaURLs) {
+ promises.push(Schemas.load(url, content));
+ }
+ for (let url of schemaURLs) {
+ promises.push(Schemas.load(url));
+ }
+ return Promise.all(promises).then(() => {
+ Schemas.updateSharedSchemas();
+ });
+ });
+ }
+
+ async loadAllModules(reverseOrder = false) {
+ await this.lazyInit();
+
+ let apiModuleNames = Array.from(this.modules.keys())
+ .filter(moduleName => {
+ let moduleDesc = this.modules.get(moduleName);
+ return moduleDesc && !!moduleDesc.url;
+ })
+ .sort();
+
+ apiModuleNames = reverseOrder ? apiModuleNames.reverse() : apiModuleNames;
+
+ for (let apiModule of apiModuleNames) {
+ info(
+ `Loading apiModule ${apiModule}: ${this.modules.get(apiModule).url}`
+ );
+ await this.asyncLoadModule(apiModule);
+ }
+ }
+}
+
+// Specialized helper class used to test loading "child process" modules (similarly to the
+// SchemaAPIManagers sub-classes defined in ExtensionPageChild.jsm and ExtensionContent.jsm).
+class FakeChildProcessAPIManager extends FakeAPIManager {
+ constructor({ processType, categoryScripts }) {
+ super(processType, Schemas);
+
+ this.categoryScripts = categoryScripts;
+ }
+
+ async lazyInit() {
+ if (!this.initialized) {
+ this.initialized = true;
+ this.initGlobal();
+ for (let { value } of Services.catMan.enumerateCategory(
+ this.categoryScripts
+ )) {
+ await this.loadScript(value);
+ }
+ }
+ }
+}
+
+async function test_loading_api_modules(createAPIManager) {
+ let fakeAPIManager;
+
+ info("Load API modules in alphabetic order");
+
+ fakeAPIManager = createAPIManager();
+ await fakeAPIManager.loadAllModules();
+
+ info("Load API modules in reverse order");
+
+ fakeAPIManager = createAPIManager();
+ await fakeAPIManager.loadAllModules(true);
+}
+
+add_task(function test_loading_main_process_api_modules() {
+ return test_loading_api_modules(() => {
+ return new FakeAPIManager();
+ });
+});
+
+add_task(function test_loading_extension_process_modules() {
+ return test_loading_api_modules(() => {
+ return new FakeChildProcessAPIManager({
+ processType: "addon",
+ categoryScripts: CATEGORY_EXTENSION_SCRIPTS_ADDON,
+ });
+ });
+});
+
+add_task(function test_loading_devtools_modules() {
+ return test_loading_api_modules(() => {
+ return new FakeChildProcessAPIManager({
+ processType: "devtools",
+ categoryScripts: CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS,
+ });
+ });
+});
+
+add_task(async function test_loading_content_process_modules() {
+ return test_loading_api_modules(() => {
+ return new FakeChildProcessAPIManager({
+ processType: "content",
+ categoryScripts: CATEGORY_EXTENSION_SCRIPTS_CONTENT,
+ });
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_locale_converter.js b/toolkit/components/extensions/test/xpcshell/test_locale_converter.js
new file mode 100644
index 0000000000..2c57b6bf31
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_locale_converter.js
@@ -0,0 +1,146 @@
+"use strict";
+
+const convService = Cc["@mozilla.org/streamConverters;1"].getService(
+ Ci.nsIStreamConverterService
+);
+
+const UUID = "72b61ee3-aceb-476c-be1b-0822b036c9f1";
+const ADDON_ID = "test@web.extension";
+const URI = NetUtil.newURI(`moz-extension://${UUID}/file.css`);
+
+const FROM_TYPE = "application/vnd.mozilla.webext.unlocalized";
+const TO_TYPE = "text/css";
+
+function StringStream(string) {
+ let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+
+ stream.data = string;
+ return stream;
+}
+
+// Initialize the policy service with a stub localizer for our
+// add-on ID.
+add_task(async function init() {
+ let policy = new WebExtensionPolicy({
+ id: ADDON_ID,
+ mozExtensionHostname: UUID,
+ baseURL: "file:///",
+
+ allowedOrigins: new MatchPatternSet([]),
+
+ localizeCallback(string) {
+ return string.replace(/__MSG_(.*?)__/g, "<localized-$1>");
+ },
+ });
+
+ policy.active = true;
+
+ registerCleanupFunction(() => {
+ policy.active = false;
+ });
+});
+
+// Test that the synchronous converter works as expected with a
+// simple string.
+add_task(async function testSynchronousConvert() {
+ let stream = StringStream("Foo __MSG_xxx__ bar __MSG_yyy__ baz");
+
+ let resultStream = convService.convert(stream, FROM_TYPE, TO_TYPE, URI);
+
+ let result = NetUtil.readInputStreamToString(
+ resultStream,
+ resultStream.available()
+ );
+
+ equal(result, "Foo <localized-xxx> bar <localized-yyy> baz");
+});
+
+// Test that the asynchronous converter works as expected with input
+// split into multiple chunks, and a boundary in the middle of a
+// replacement token.
+add_task(async function testAsyncConvert() {
+ let listener;
+ let awaitResult = new Promise((resolve, reject) => {
+ listener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+
+ onDataAvailable(request, inputStream, offset, count) {
+ this.resultParts.push(
+ NetUtil.readInputStreamToString(inputStream, count)
+ );
+ },
+
+ onStartRequest() {
+ ok(!("resultParts" in this));
+ this.resultParts = [];
+ },
+
+ onStopRequest(request, context, statusCode) {
+ if (!Components.isSuccessCode(statusCode)) {
+ reject(new Error(statusCode));
+ }
+
+ resolve(this.resultParts.join("\n"));
+ },
+ };
+ });
+
+ let parts = ["Foo __MSG_x", "xx__ bar __MSG_yyy__ baz"];
+
+ let converter = convService.asyncConvertData(
+ FROM_TYPE,
+ TO_TYPE,
+ listener,
+ URI
+ );
+ converter.onStartRequest(null, null);
+
+ for (let part of parts) {
+ converter.onDataAvailable(null, StringStream(part), 0, part.length);
+ }
+
+ converter.onStopRequest(null, null, Cr.NS_OK);
+
+ let result = await awaitResult;
+ equal(result, "Foo <localized-xxx> bar <localized-yyy> baz");
+});
+
+// Test that attempting to initialize a converter with the URI of a
+// nonexistent WebExtension fails.
+add_task(async function testInvalidUUID() {
+ let uri = NetUtil.newURI(
+ "moz-extension://eb4f3be8-41c9-4970-aa6d-b84d1ecc02b2/file.css"
+ );
+ let stream = StringStream("Foo __MSG_xxx__ bar __MSG_yyy__ baz");
+
+ // Assert.throws raise a TypeError exception when the expected param
+ // is an arrow function. (See Bug 1237961 for rationale)
+ let expectInvalidContextException = function (e) {
+ return e.result === Cr.NS_ERROR_INVALID_ARG && /Invalid context/.test(e);
+ };
+
+ Assert.throws(() => {
+ convService.convert(stream, FROM_TYPE, TO_TYPE, uri);
+ }, expectInvalidContextException);
+
+ Assert.throws(() => {
+ let listener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+ };
+
+ convService.asyncConvertData(FROM_TYPE, TO_TYPE, listener, uri);
+ }, expectInvalidContextException);
+});
+
+// Test that an empty stream does not throw an NS_ERROR_ILLEGAL_VALUE.
+add_task(async function testEmptyStream() {
+ let stream = StringStream("");
+ let resultStream = convService.convert(stream, FROM_TYPE, TO_TYPE, URI);
+ equal(
+ resultStream.available(),
+ 0,
+ "Size of output stream should match size of input stream"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_locale_data.js b/toolkit/components/extensions/test/xpcshell/test_locale_data.js
new file mode 100644
index 0000000000..31468e91d3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_locale_data.js
@@ -0,0 +1,221 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+const { ExtensionData } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+);
+
+async function generateAddon(data) {
+ let xpi = AddonTestUtils.createTempWebExtensionFile(data);
+
+ let fileURI = Services.io.newFileURI(xpi);
+ let jarURI = NetUtil.newURI(`jar:${fileURI.spec}!/`);
+
+ let extension = new ExtensionData(jarURI, false);
+ await extension.loadManifest();
+
+ return extension;
+}
+
+add_task(async function testMissingDefaultLocale() {
+ let extension = await generateAddon({
+ files: {
+ "_locales/en_US/messages.json": {},
+ },
+ });
+
+ equal(extension.errors.length, 0, "No errors reported");
+
+ await extension.initAllLocales();
+
+ equal(extension.errors.length, 1, "One error reported");
+
+ info(`Got error: ${extension.errors[0]}`);
+
+ ok(
+ extension.errors[0].includes('"default_locale" property is required'),
+ "Got missing default_locale error"
+ );
+});
+
+add_task(async function testInvalidDefaultLocale() {
+ let extension = await generateAddon({
+ manifest: {
+ default_locale: "en",
+ },
+
+ files: {
+ "_locales/en_US/messages.json": {},
+ },
+ });
+
+ equal(extension.errors.length, 1, "One error reported");
+
+ info(`Got error: ${extension.errors[0]}`);
+
+ ok(
+ extension.errors[0].includes(
+ "Loading locale file _locales/en/messages.json"
+ ),
+ "Got invalid default_locale error"
+ );
+
+ await extension.initAllLocales();
+
+ equal(extension.errors.length, 2, "Two errors reported");
+
+ info(`Got error: ${extension.errors[1]}`);
+
+ ok(
+ extension.errors[1].includes('"default_locale" property must correspond'),
+ "Got invalid default_locale error"
+ );
+});
+
+add_task(async function testUnexpectedDefaultLocale() {
+ let extension = await generateAddon({
+ manifest: {
+ default_locale: "en_US",
+ },
+ });
+
+ equal(extension.errors.length, 1, "One error reported");
+
+ info(`Got error: ${extension.errors[0]}`);
+
+ ok(
+ extension.errors[0].includes(
+ "Loading locale file _locales/en-US/messages.json"
+ ),
+ "Got invalid default_locale error"
+ );
+
+ await extension.initAllLocales();
+
+ equal(extension.errors.length, 2, "One error reported");
+
+ info(`Got error: ${extension.errors[1]}`);
+
+ ok(
+ extension.errors[1].includes('"default_locale" property must correspond'),
+ "Got unexpected default_locale error"
+ );
+});
+
+add_task(async function testInvalidSyntax() {
+ let extension = await generateAddon({
+ manifest: {
+ default_locale: "en_US",
+ },
+
+ files: {
+ "_locales/en_US/messages.json":
+ '{foo: {message: "bar", description: "baz"}}',
+ },
+ });
+
+ equal(extension.errors.length, 1, "No errors reported");
+
+ info(`Got error: ${extension.errors[0]}`);
+
+ ok(
+ extension.errors[0].includes(
+ "Loading locale file _locales/en_US/messages.json: SyntaxError"
+ ),
+ "Got syntax error"
+ );
+
+ await extension.initAllLocales();
+
+ equal(extension.errors.length, 2, "One error reported");
+
+ info(`Got error: ${extension.errors[1]}`);
+
+ ok(
+ extension.errors[1].includes(
+ "Loading locale file _locales/en_US/messages.json: SyntaxError"
+ ),
+ "Got syntax error"
+ );
+});
+
+add_task(async function testExtractLocalizedManifest() {
+ let extension = await generateAddon({
+ manifest: {
+ name: "__MSG_extensionName__",
+ default_locale: "en_US",
+ icons: {
+ 16: "__MSG_extensionIcon__",
+ },
+ },
+
+ files: {
+ "_locales/en_US/messages.json": `{
+ "extensionName": {"message": "foo"},
+ "extensionIcon": {"message": "icon-en.png"}
+ }`,
+ "_locales/de_DE/messages.json": `{
+ "extensionName": {"message": "bar"},
+ "extensionIcon": {"message": "icon-de.png"}
+ }`,
+ },
+ });
+
+ await extension.loadManifest();
+ equal(extension.manifest.name, "foo", "name localized");
+ equal(extension.manifest.icons["16"], "icon-en.png", "icons localized");
+
+ let manifest = await extension.getLocalizedManifest("de-DE");
+ ok(extension.localeData.has("de-DE"), "has de_DE locale");
+ equal(manifest.name, "bar", "name localized");
+ equal(manifest.icons["16"], "icon-de.png", "icons localized");
+
+ await Assert.rejects(
+ extension.getLocalizedManifest("xx-XX"),
+ /does not contain the locale xx-XX/,
+ "xx-XX does not exist"
+ );
+});
+
+add_task(async function testRestartThenExtractLocalizedManifest() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let wrapper = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "__MSG_extensionName__",
+ default_locale: "en_US",
+ },
+ useAddonManager: "permanent",
+ files: {
+ "_locales/en_US/messages.json": '{"extensionName": {"message": "foo"}}',
+ "_locales/de_DE/messages.json": '{"extensionName": {"message": "bar"}}',
+ },
+ });
+
+ await wrapper.startup();
+
+ await AddonTestUtils.promiseRestartManager();
+ await wrapper.startupPromise;
+
+ let { extension } = wrapper;
+ let manifest = await extension.getLocalizedManifest("de-DE");
+ ok(extension.localeData.has("de-DE"), "has de_DE locale");
+ equal(manifest.name, "bar", "name localized");
+
+ await Assert.rejects(
+ extension.getLocalizedManifest("xx-XX"),
+ /does not contain the locale xx-XX/,
+ "xx-XX does not exist"
+ );
+
+ await wrapper.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_native_manifests.js b/toolkit/components/extensions/test/xpcshell/test_native_manifests.js
new file mode 100644
index 0000000000..d28e692a08
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_native_manifests.js
@@ -0,0 +1,541 @@
+"use strict";
+
+const { AsyncShutdown } = ChromeUtils.importESModule(
+ "resource://gre/modules/AsyncShutdown.sys.mjs"
+);
+const { NativeManifests } = ChromeUtils.importESModule(
+ "resource://gre/modules/NativeManifests.sys.mjs"
+);
+const { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+const { Schemas } = ChromeUtils.importESModule(
+ "resource://gre/modules/Schemas.sys.mjs"
+);
+const { Subprocess } = ChromeUtils.importESModule(
+ "resource://gre/modules/Subprocess.sys.mjs"
+);
+const { NativeApp } = ChromeUtils.importESModule(
+ "resource://gre/modules/NativeMessaging.sys.mjs"
+);
+
+let registry = null;
+if (AppConstants.platform == "win") {
+ var { MockRegistry } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistry.sys.mjs"
+ );
+ registry = new MockRegistry();
+ registerCleanupFunction(() => {
+ registry.shutdown();
+ });
+ ChromeUtils.defineESModuleGetters(this, {
+ SubprocessImpl: "resource://gre/modules/subprocess/subprocess_win.sys.mjs",
+ });
+} else {
+ ChromeUtils.defineESModuleGetters(this, {
+ SubprocessImpl: "resource://gre/modules/subprocess/subprocess_unix.sys.mjs",
+ });
+}
+
+const REGPATH = "Software\\Mozilla\\NativeMessagingHosts";
+
+const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json";
+
+const TYPE_SLUG =
+ AppConstants.platform === "linux"
+ ? "native-messaging-hosts"
+ : "NativeMessagingHosts";
+
+let dir = FileUtils.getDir("TmpD", ["NativeManifests"]);
+dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+let userDir = dir.clone();
+userDir.append("user");
+userDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+let globalDir = dir.clone();
+globalDir.append("global");
+globalDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+add_setup(async function setup() {
+ await IOUtils.makeDirectory(PathUtils.join(userDir.path, TYPE_SLUG));
+ await IOUtils.makeDirectory(PathUtils.join(globalDir.path, TYPE_SLUG));
+});
+
+let dirProvider = {
+ getFile(property) {
+ if (property == "XREUserNativeManifests") {
+ return userDir.clone();
+ } else if (property == "XRESysNativeManifests") {
+ return globalDir.clone();
+ }
+ return null;
+ },
+};
+
+Services.dirsvc.registerProvider(dirProvider);
+
+registerCleanupFunction(() => {
+ Services.dirsvc.unregisterProvider(dirProvider);
+ dir.remove(true);
+});
+
+function writeManifest(path, manifest) {
+ if (typeof manifest != "string") {
+ manifest = JSON.stringify(manifest);
+ }
+ return IOUtils.writeUTF8(path, manifest);
+}
+
+let PYTHON;
+add_task(async function setup() {
+ await Schemas.load(BASE_SCHEMA);
+
+ try {
+ PYTHON = await Subprocess.pathSearch(Services.env.get("PYTHON"));
+ } catch (e) {
+ notEqual(
+ PYTHON,
+ null,
+ `Can't find a suitable python interpreter ${e.message}`
+ );
+ }
+});
+
+let global = this;
+
+// Test of NativeManifests.lookupApplication() begin here...
+let context = {
+ extension: {
+ id: "extension@tests.mozilla.org",
+ },
+ manifestVersion: 2,
+ envType: "addon_parent",
+ url: null,
+ jsonStringify(...args) {
+ return JSON.stringify(...args);
+ },
+ cloneScope: global,
+ logError() {},
+ preprocessors: {},
+ callOnClose: () => {},
+ forgetOnClose: () => {},
+};
+
+class MockContext extends ExtensionCommon.BaseContext {
+ constructor(extensionId) {
+ let fakeExtension = { id: extensionId, manifestVersion: 2 };
+ super("addon_parent", fakeExtension);
+ this.sandbox = Cu.Sandbox(global);
+ }
+
+ get cloneScope() {
+ return global;
+ }
+
+ get principal() {
+ return Cu.getObjectPrincipal(this.sandbox);
+ }
+}
+
+let templateManifest = {
+ name: "test",
+ description: "this is only a test",
+ path: "/bin/cat",
+ type: "stdio",
+ allowed_extensions: ["extension@tests.mozilla.org"],
+};
+
+function lookupApplication(app, ctx) {
+ return NativeManifests.lookupManifest("stdio", app, ctx);
+}
+
+add_task(async function test_nonexistent_manifest() {
+ let result = await lookupApplication("test", context);
+ equal(
+ result,
+ null,
+ "lookupApplication returns null for non-existent application"
+ );
+});
+
+const USER_TEST_JSON = PathUtils.join(userDir.path, TYPE_SLUG, "test.json");
+
+add_task(async function test_nonexistent_manifest_with_registry_entry() {
+ if (registry) {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGPATH}\\test`,
+ "",
+ USER_TEST_JSON
+ );
+ }
+
+ await IOUtils.remove(USER_TEST_JSON);
+ let { messages, result } = await promiseConsoleOutput(() =>
+ lookupApplication("test", context)
+ );
+ equal(
+ result,
+ null,
+ "lookupApplication returns null for non-existent manifest"
+ );
+
+ let noSuchFileErrors = messages.filter(logMessage =>
+ logMessage.message.includes(
+ "file is referenced in the registry but does not exist"
+ )
+ );
+
+ if (registry) {
+ equal(
+ noSuchFileErrors.length,
+ 1,
+ "lookupApplication logs a non-existent manifest file pointed to by the registry"
+ );
+ } else {
+ equal(
+ noSuchFileErrors.length,
+ 0,
+ "lookupApplication does not log about registry on non-windows platforms"
+ );
+ }
+});
+
+add_task(async function test_good_manifest() {
+ await writeManifest(USER_TEST_JSON, templateManifest);
+ if (registry) {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGPATH}\\test`,
+ "",
+ USER_TEST_JSON
+ );
+ }
+
+ let result = await lookupApplication("test", context);
+ notEqual(result, null, "lookupApplication finds a good manifest");
+ equal(
+ result.path,
+ USER_TEST_JSON,
+ "lookupApplication returns the correct path"
+ );
+ deepEqual(
+ result.manifest,
+ templateManifest,
+ "lookupApplication returns the manifest contents"
+ );
+});
+
+add_task(
+ { skip_if: () => AppConstants.platform != "win" },
+ async function test_forward_slashes_instead_of_backslashes_in_registry() {
+ Assert.ok(USER_TEST_JSON.includes("\\"), `Path has \\: ${USER_TEST_JSON}`);
+ const manifest = { ...templateManifest, name: "testslash" };
+ await writeManifest(USER_TEST_JSON, manifest);
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGPATH}\\testslash`,
+ "",
+ USER_TEST_JSON.replaceAll("\\", "/")
+ );
+
+ let result = await lookupApplication("testslash", context);
+ notEqual(result, null, "lookupApplication finds the manifest despite /");
+ equal(
+ result.path,
+ USER_TEST_JSON,
+ "lookupApplication returns the correct path with platform-native slash"
+ );
+ // Side note: manifest.path does not contain a platform-native path,
+ // but it is normalized when used in NativeMessaging.jsm.
+ deepEqual(
+ result.manifest,
+ manifest,
+ "lookupApplication returns the manifest contents"
+ );
+ }
+);
+
+add_task(async function test_manifest_with_utf8_byte_order_mark() {
+ const manifest = { ...templateManifest, description: "had BOM at start" };
+ const manifestString = JSON.stringify(manifest);
+
+ // "123" to have a placeholder where we'll fill in the 3 BOM bytes.
+ const manifestBytes = new TextEncoder().encode("123" + manifestString);
+ manifestBytes.set([0xef, 0xbb, 0xbf]);
+
+ // Sanity check: verify that the bytes prepended above have the special
+ // meaning of being a UTF-8 BOM. That is, when parsed as UTF-8, the bytes can
+ // be removed without loss of meaning.
+ equal(
+ new TextDecoder().decode(manifestBytes),
+ manifestString,
+ "Sanity check: input bytes has UTF-8 BOM that is ordinarily stripped"
+ );
+
+ await IOUtils.write(USER_TEST_JSON, manifestBytes);
+ if (registry) {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGPATH}\\test`,
+ "",
+ USER_TEST_JSON
+ );
+ }
+ let result = await lookupApplication("test", context);
+ notEqual(result, null, "lookupApplication finds a good manifest despite BOM");
+ deepEqual(
+ result.manifest,
+ manifest,
+ "lookupApplication returns the manifest contents"
+ );
+});
+
+add_task(async function test_manifest_with_invalid_utf_8() {
+ const manifest = { ...templateManifest, description: "bad bytes" };
+ const manifestString = JSON.stringify(manifest);
+ const manifestBytes = Uint8Array.from(manifestString, c => c.charCodeAt(0));
+ // manifestString ends with `bad bytes"}`. Replace the `s` with a bad byte:
+ manifestBytes.set([0xff], manifestBytes.byteLength - 3);
+
+ await IOUtils.write(USER_TEST_JSON, manifestBytes);
+ if (registry) {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGPATH}\\test`,
+ "",
+ USER_TEST_JSON
+ );
+ }
+ // Note: most content failures (malformed JSON, etc.) do not result in a
+ // rejection, but in the manifest file being ignored (and on non-Windows:
+ // looking for manifests in the next location).
+ //
+ // It is inconsistent to reject instead of be returning null here, but that
+ // behavior seems long-standing in Firefox.
+ await Assert.rejects(
+ lookupApplication("test", context),
+ /NotReadableError: Could not read file.* because it is not UTF-8 encoded/,
+ "lookupApplication should reject file with invalid UTF8"
+ );
+});
+
+add_task(async function test_invalid_json() {
+ await writeManifest(USER_TEST_JSON, "this is not valid json");
+ let result = await lookupApplication("test", context);
+ equal(result, null, "lookupApplication ignores bad json");
+});
+
+add_task(async function test_invalid_name() {
+ let manifest = Object.assign({}, templateManifest);
+ manifest.name = "../test";
+ await writeManifest(USER_TEST_JSON, manifest);
+ let result = await lookupApplication("test", context);
+ equal(result, null, "lookupApplication ignores an invalid name");
+});
+
+add_task(async function test_name_mismatch() {
+ let manifest = Object.assign({}, templateManifest);
+ manifest.name = "not test";
+ await writeManifest(USER_TEST_JSON, manifest);
+ let result = await lookupApplication("test", context);
+ let what = AppConstants.platform == "win" ? "registry key" : "json filename";
+ equal(
+ result,
+ null,
+ `lookupApplication ignores mistmatch between ${what} and name property`
+ );
+});
+
+add_task(async function test_missing_props() {
+ const PROPS = ["name", "description", "path", "type", "allowed_extensions"];
+ for (let prop of PROPS) {
+ let manifest = Object.assign({}, templateManifest);
+ delete manifest[prop];
+
+ await writeManifest(USER_TEST_JSON, manifest);
+ let result = await lookupApplication("test", context);
+ equal(result, null, `lookupApplication ignores missing ${prop}`);
+ }
+});
+
+add_task(async function test_invalid_type() {
+ let manifest = Object.assign({}, templateManifest);
+ manifest.type = "bogus";
+ await writeManifest(USER_TEST_JSON, manifest);
+ let result = await lookupApplication("test", context);
+ equal(result, null, "lookupApplication ignores invalid type");
+});
+
+add_task(async function test_no_allowed_extensions() {
+ let manifest = Object.assign({}, templateManifest);
+ manifest.allowed_extensions = [];
+ await writeManifest(USER_TEST_JSON, manifest);
+ let result = await lookupApplication("test", context);
+ equal(
+ result,
+ null,
+ "lookupApplication ignores manifest with no allowed_extensions"
+ );
+});
+
+const GLOBAL_TEST_JSON = PathUtils.join(globalDir.path, TYPE_SLUG, "test.json");
+let globalManifest = Object.assign({}, templateManifest);
+globalManifest.description = "This manifest is from the systemwide directory";
+
+add_task(async function good_manifest_system_dir() {
+ await IOUtils.remove(USER_TEST_JSON);
+ await writeManifest(GLOBAL_TEST_JSON, globalManifest);
+ if (registry) {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGPATH}\\test`,
+ "",
+ null
+ );
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ `${REGPATH}\\test`,
+ "",
+ GLOBAL_TEST_JSON
+ );
+ }
+
+ let where =
+ AppConstants.platform == "win" ? "registry location" : "directory";
+ let result = await lookupApplication("test", context);
+ notEqual(
+ result,
+ null,
+ `lookupApplication finds a manifest in the system-wide ${where}`
+ );
+ equal(
+ result.path,
+ GLOBAL_TEST_JSON,
+ `lookupApplication returns path in the system-wide ${where}`
+ );
+ deepEqual(
+ result.manifest,
+ globalManifest,
+ `lookupApplication returns manifest contents from the system-wide ${where}`
+ );
+});
+
+add_task(async function test_user_dir_precedence() {
+ await writeManifest(USER_TEST_JSON, templateManifest);
+ if (registry) {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGPATH}\\test`,
+ "",
+ USER_TEST_JSON
+ );
+ }
+ // global test.json and LOCAL_MACHINE registry key on windows are
+ // still present from the previous test
+
+ let result = await lookupApplication("test", context);
+ notEqual(
+ result,
+ null,
+ "lookupApplication finds a manifest when entries exist in both user-specific and system-wide locations"
+ );
+ equal(
+ result.path,
+ USER_TEST_JSON,
+ "lookupApplication returns the user-specific path when user-specific and system-wide entries both exist"
+ );
+ deepEqual(
+ result.manifest,
+ templateManifest,
+ "lookupApplication returns user-specific manifest contents with user-specific and system-wide entries both exist"
+ );
+});
+
+// Test shutdown handling in NativeApp
+add_task(async function test_native_app_shutdown() {
+ const SCRIPT = String.raw`
+import signal
+import struct
+import sys
+
+signal.signal(signal.SIGTERM, signal.SIG_IGN)
+
+stdin = getattr(sys.stdin, 'buffer', sys.stdin)
+stdout = getattr(sys.stdout, 'buffer', sys.stdout)
+
+while True:
+ rawlen = stdin.read(4)
+ if len(rawlen) == 0:
+ signal.pause()
+ msglen = struct.unpack('@I', rawlen)[0]
+ msg = stdin.read(msglen)
+
+ stdout.write(struct.pack('@I', msglen))
+ stdout.write(msg)
+`;
+
+ let scriptPath = PathUtils.join(userDir.path, TYPE_SLUG, "wontdie.py");
+ let manifestPath = PathUtils.join(userDir.path, TYPE_SLUG, "wontdie.json");
+
+ const ID = "native@tests.mozilla.org";
+ let manifest = {
+ name: "wontdie",
+ description: "test async shutdown of native apps",
+ type: "stdio",
+ allowed_extensions: [ID],
+ };
+
+ if (AppConstants.platform == "win") {
+ await IOUtils.writeUTF8(scriptPath, SCRIPT);
+
+ let batPath = PathUtils.join(userDir.path, TYPE_SLUG, "wontdie.bat");
+ let batBody = `@ECHO OFF\n${PYTHON} -u "${scriptPath}" %*\n`;
+ await IOUtils.writeUTF8(batPath, batBody);
+ await IOUtils.setPermissions(batPath, 0o755);
+
+ manifest.path = batPath;
+ await writeManifest(manifestPath, manifest);
+
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGPATH}\\wontdie`,
+ "",
+ manifestPath
+ );
+ } else {
+ await IOUtils.writeUTF8(scriptPath, `#!${PYTHON} -u\n${SCRIPT}`);
+ await IOUtils.setPermissions(scriptPath, 0o755);
+ manifest.path = scriptPath;
+ await writeManifest(manifestPath, manifest);
+ }
+
+ let mockContext = new MockContext(ID);
+ let app = new NativeApp(mockContext, "wontdie");
+
+ // send a message and wait for the reply to make sure the app is running
+ let MSG = "test";
+ let recvPromise = new Promise(resolve => {
+ let listener = (what, msg) => {
+ equal(msg, MSG, "Received test message");
+ app.off("message", listener);
+ resolve();
+ };
+ app.on("message", listener);
+ });
+
+ let buffer = NativeApp.encodeMessage(mockContext, MSG);
+ app.send(new StructuredCloneHolder("", null, buffer));
+ await recvPromise;
+
+ app._cleanup();
+
+ info("waiting for async shutdown");
+ Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true);
+ AsyncShutdown.profileBeforeChange._trigger();
+ Services.prefs.clearUserPref("toolkit.asyncshutdown.testing");
+
+ let procs = await SubprocessImpl.Process.getWorker().call("getProcesses", []);
+ equal(procs.size, 0, "native process exited");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_failover.js b/toolkit/components/extensions/test/xpcshell/test_proxy_failover.js
new file mode 100644
index 0000000000..e584f142fa
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_failover.js
@@ -0,0 +1,323 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+// Necessary for the pac script to proxy localhost requests
+Services.prefs.setBoolPref("network.proxy.allow_hijacking_localhost", true);
+
+// Pref is not builtin if direct failover is disabled in compile config.
+XPCOMUtils.defineLazyGetter(this, "directFailoverDisabled", () => {
+ return (
+ Services.prefs.getPrefType("network.proxy.failover_direct") ==
+ Ci.nsIPrefBranch.PREF_INVALID
+ );
+});
+
+const { ServiceRequest } = ChromeUtils.importESModule(
+ "resource://gre/modules/ServiceRequest.sys.mjs"
+);
+
+// Prevent the request from reaching out to the network.
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+// No hosts defined to avoid the default proxy filter setup.
+const nonProxiedServer = createHttpServer();
+nonProxiedServer.registerPathHandler("/", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok!");
+});
+const { primaryHost, primaryPort } = nonProxiedServer.identity;
+
+function getProxyData(channel) {
+ if (!(channel instanceof Ci.nsIProxiedChannel) || !channel.proxyInfo) {
+ return;
+ }
+ let { type, host, port, sourceId } = channel.proxyInfo;
+ return { type, host, port, sourceId };
+}
+
+// Get a free port with no listener to use in the proxyinfo.
+function getBadProxyPort() {
+ let server = new HttpServer();
+ server.start(-1);
+ const badPort = server.identity.primaryPort;
+ server.stop();
+ return badPort;
+}
+
+function xhr(url, options = { beConservative: true, bypassProxy: false }) {
+ return new Promise((resolve, reject) => {
+ let req = new XMLHttpRequest();
+ req.open("GET", `${url}?t=${Math.random()}`);
+ req.channel.QueryInterface(Ci.nsIHttpChannelInternal).beConservative =
+ options.beConservative;
+ req.channel.QueryInterface(Ci.nsIHttpChannelInternal).bypassProxy =
+ options.bypassProxy;
+ req.onload = () => {
+ resolve({ text: req.responseText, proxy: getProxyData(req.channel) });
+ };
+ req.onerror = () => {
+ reject({ status: req.status, proxy: getProxyData(req.channel) });
+ };
+ req.send();
+ });
+}
+
+// Same as the above xhr call, but ServiceRequest is always beConservative.
+// This is here to specifically test bypassProxy with ServiceRequest.
+function serviceRequest(url, options = { bypassProxy: false }) {
+ return new Promise((resolve, reject) => {
+ let req = new ServiceRequest();
+ req.open("GET", `${url}?t=${Math.random()}`, options);
+ req.onload = () => {
+ resolve({ text: req.responseText, proxy: getProxyData(req.channel) });
+ };
+ req.onerror = () => {
+ reject({ status: req.status, proxy: getProxyData(req.channel) });
+ };
+ req.send();
+ });
+}
+
+add_task(async function setup() {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+async function getProxyExtension(proxyDetails) {
+ async function background(proxyDetails) {
+ browser.proxy.onRequest.addListener(
+ details => {
+ return proxyDetails;
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.test.sendMessage("proxied");
+ }
+ let extensionData = {
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background: `(${background})(${JSON.stringify(proxyDetails)})`,
+ incognitoOverride: "spanning",
+ useAddonManager: "temporary",
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("proxied");
+ return extension;
+}
+
+add_task(async function test_failover_content_direct() {
+ // load a content page for fetch and non-system principal, expect
+ // failover to direct will work.
+ const proxyDetails = [
+ { type: "http", host: "127.0.0.1", port: getBadProxyPort() },
+ { type: "direct" },
+ ];
+
+ // We need to load the content page before loading the proxy extension
+ // to ensure that we have a valid content page to run fetch from.
+ let contentUrl = `http://${primaryHost}:${primaryPort}/`;
+ let page = await ExtensionTestUtils.loadContentPage(contentUrl);
+
+ let extension = await getProxyExtension(proxyDetails);
+
+ await ExtensionTestUtils.fetch(contentUrl, `${contentUrl}?t=${Math.random()}`)
+ .then(text => {
+ equal(text, "ok!", "fetch completed");
+ })
+ .catch(() => {
+ ok(false, "fetch failed");
+ });
+
+ await extension.unload();
+ await page.close();
+});
+
+add_task(
+ { skip_if: () => directFailoverDisabled },
+ async function test_failover_content() {
+ // load a content page for fetch and non-system principal, expect
+ // no failover
+ const proxyDetails = [
+ { type: "http", host: "127.0.0.1", port: getBadProxyPort() },
+ ];
+
+ // We need to load the content page before loading the proxy extension
+ // to ensure that we have a valid content page to run fetch from.
+ let contentUrl = `http://${primaryHost}:${primaryPort}/`;
+ let page = await ExtensionTestUtils.loadContentPage(contentUrl);
+
+ let extension = await getProxyExtension(proxyDetails);
+
+ await ExtensionTestUtils.fetch(
+ contentUrl,
+ `${contentUrl}?t=${Math.random()}`
+ )
+ .then(text => {
+ ok(false, "xhr unexpectedly completed");
+ })
+ .catch(e => {
+ equal(
+ e.message,
+ "NetworkError when attempting to fetch resource.",
+ "fetch failed"
+ );
+ });
+
+ await extension.unload();
+ await page.close();
+ }
+);
+
+add_task(
+ { skip_if: () => directFailoverDisabled },
+ async function test_failover_system() {
+ const proxyDetails = [
+ { type: "http", host: "127.0.0.1", port: getBadProxyPort() },
+ { type: "http", host: "127.0.0.1", port: getBadProxyPort() },
+ ];
+
+ let extension = await getProxyExtension(proxyDetails);
+
+ await xhr(`http://${primaryHost}:${primaryPort}/`)
+ .then(req => {
+ equal(req.proxy.type, "direct", "proxy failover to direct");
+ equal(req.text, "ok!", "xhr completed");
+ })
+ .catch(req => {
+ ok(false, "xhr failed");
+ });
+
+ await extension.unload();
+ }
+);
+
+add_task(
+ {
+ skip_if: () =>
+ AppConstants.platform === "android" || directFailoverDisabled,
+ },
+ async function test_failover_pac() {
+ const badPort = getBadProxyPort();
+
+ async function background(badPort) {
+ let pac = `function FindProxyForURL(url, host) { return "PROXY 127.0.0.1:${badPort}"; }`;
+ let proxySettings = {
+ proxyType: "autoConfig",
+ autoConfigUrl: `data:application/x-ns-proxy-autoconfig;charset=utf-8,${encodeURIComponent(
+ pac
+ )}`,
+ };
+
+ await browser.proxy.settings.set({ value: proxySettings });
+ browser.test.sendMessage("proxied");
+ }
+ let extensionData = {
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background: `(${background})(${badPort})`,
+ incognitoOverride: "spanning",
+ useAddonManager: "temporary",
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("proxied");
+ equal(
+ Services.prefs.getIntPref("network.proxy.type"),
+ 2,
+ "autoconfig type set"
+ );
+ ok(
+ Services.prefs.getStringPref("network.proxy.autoconfig_url"),
+ "autoconfig url set"
+ );
+
+ await xhr(`http://${primaryHost}:${primaryPort}/`)
+ .then(req => {
+ equal(req.proxy.type, "direct", "proxy failover to direct");
+ equal(req.text, "ok!", "xhr completed");
+ })
+ .catch(() => {
+ ok(false, "xhr failed");
+ });
+
+ await extension.unload();
+ }
+);
+
+add_task(async function test_bypass_proxy() {
+ const proxyDetails = [
+ { type: "http", host: "127.0.0.1", port: getBadProxyPort() },
+ ];
+
+ let extension = await getProxyExtension(proxyDetails);
+
+ await xhr(`http://${primaryHost}:${primaryPort}/`, { bypassProxy: true })
+ .then(req => {
+ equal(req.proxy, undefined, "no proxy used");
+ ok(true, "xhr completed");
+ })
+ .catch(req => {
+ equal(req.proxy, undefined, "no proxy used");
+ ok(false, "xhr error");
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_bypass_proxy() {
+ const proxyDetails = [
+ { type: "http", host: "127.0.0.1", port: getBadProxyPort() },
+ ];
+
+ let extension = await getProxyExtension(proxyDetails);
+
+ await serviceRequest(`http://${primaryHost}:${primaryPort}/`, {
+ bypassProxy: true,
+ })
+ .then(req => {
+ equal(req.proxy, undefined, "no proxy used");
+ ok(true, "xhr completed");
+ })
+ .catch(req => {
+ equal(req.proxy, undefined, "no proxy used");
+ ok(false, "xhr error");
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_failover_system_off() {
+ // Test test failover failures, uncomment and set pref to false
+ Services.prefs.setBoolPref("network.proxy.failover_direct", false);
+
+ const proxyDetails = [
+ { type: "http", host: "127.0.0.1", port: getBadProxyPort() },
+ ];
+
+ let extension = await getProxyExtension(proxyDetails);
+
+ await xhr(`http://${primaryHost}:${primaryPort}/`)
+ .then(req => {
+ equal(req.proxy.sourceId, extension.id, "extension matches");
+ ok(false, "xhr completed");
+ })
+ .catch(req => {
+ equal(req.proxy.sourceId, extension.id, "extension matches");
+ equal(req.status, 0, "xhr failed");
+ });
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js b/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js
new file mode 100644
index 0000000000..a37996c221
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js
@@ -0,0 +1,95 @@
+"use strict";
+
+/* eslint no-unused-vars: ["error", {"args": "none", "varsIgnorePattern": "^(FindProxyForURL)$"}] */
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_incognito_proxy_onRequest_access() {
+ // This extension will fail if it gets a private request.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ async background() {
+ browser.proxy.onRequest.addListener(
+ async details => {
+ browser.test.assertFalse(
+ details.incognito,
+ "incognito flag is not set"
+ );
+ browser.test.notifyPass("proxy.onRequest");
+ },
+ { urls: ["<all_urls>"], types: ["main_frame"] }
+ );
+
+ // Actual call arguments do not matter here.
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "none",
+ },
+ }),
+ /proxy.settings requires private browsing permission/,
+ "proxy.settings requires private browsing permission."
+ );
+
+ browser.test.sendMessage("ready");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ let pextension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background() {
+ browser.proxy.onRequest.addListener(
+ async details => {
+ browser.test.assertTrue(
+ details.incognito,
+ "incognito flag is set with filter"
+ );
+ browser.test.sendMessage("proxy.onRequest.private");
+ },
+ { urls: ["<all_urls>"], types: ["main_frame"], incognito: true }
+ );
+
+ browser.proxy.onRequest.addListener(
+ async details => {
+ browser.test.assertFalse(
+ details.incognito,
+ "incognito flag is not set with filter"
+ );
+ browser.test.notifyPass("proxy.onRequest.spanning");
+ },
+ { urls: ["<all_urls>"], types: ["main_frame"], incognito: false }
+ );
+ },
+ });
+ await pextension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "https://example.com/dummy",
+ { privateBrowsing: true }
+ );
+ await pextension.awaitMessage("proxy.onRequest.private");
+ await contentPage.close();
+
+ contentPage = await ExtensionTestUtils.loadContentPage(
+ "https://example.com/dummy"
+ );
+ await extension.awaitFinish("proxy.onRequest");
+ await pextension.awaitFinish("proxy.onRequest.spanning");
+ await contentPage.close();
+
+ await pextension.unload();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js b/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js
new file mode 100644
index 0000000000..0a7e1422d2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js
@@ -0,0 +1,462 @@
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gProxyService",
+ "@mozilla.org/network/protocol-proxy-service;1",
+ "nsIProtocolProxyService"
+);
+
+const TRANSPARENT_PROXY_RESOLVES_HOST =
+ Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST;
+
+let extension;
+add_task(async function setup() {
+ let extensionData = {
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background() {
+ let settings = { proxy: null };
+
+ browser.proxy.onError.addListener(error => {
+ browser.test.log(`error received ${error.message}`);
+ browser.test.sendMessage("proxy-error-received", error);
+ });
+ browser.test.onMessage.addListener((message, data) => {
+ if (message === "set-proxy") {
+ settings.proxy = data.proxy;
+ browser.test.sendMessage("proxy-set", settings.proxy);
+ }
+ });
+ browser.proxy.onRequest.addListener(
+ () => {
+ return settings.proxy;
+ },
+ { urls: ["<all_urls>"] }
+ );
+ },
+ };
+ extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+});
+
+async function setupProxyResult(proxy) {
+ extension.sendMessage("set-proxy", { proxy });
+ let proxyInfoSent = await extension.awaitMessage("proxy-set");
+ deepEqual(
+ proxyInfoSent,
+ proxy,
+ "got back proxy data from the proxy listener"
+ );
+}
+
+async function testProxyResolution(test) {
+ let { uri, proxy, expected } = test;
+ let errorMsg;
+ if (expected.error) {
+ errorMsg = extension.awaitMessage("proxy-error-received");
+ }
+ let proxyInfo = await new Promise((resolve, reject) => {
+ let channel = NetUtil.newChannel({
+ uri,
+ loadUsingSystemPrincipal: true,
+ });
+
+ gProxyService.asyncResolve(channel, 0, {
+ onProxyAvailable(req, uri, pi, status) {
+ resolve(pi && pi.QueryInterface(Ci.nsIProxyInfo));
+ },
+ });
+ });
+
+ let expectedProxyInfo = expected.proxyInfo;
+ if (expected.error) {
+ equal(proxyInfo, null, "Expected proxyInfo to be null");
+ equal((await errorMsg).message, expected.error, "error received");
+ } else if (proxy == null) {
+ equal(proxyInfo, expectedProxyInfo, "proxy is direct");
+ } else {
+ for (
+ let proxyUsed = proxyInfo;
+ proxyUsed;
+ proxyUsed = proxyUsed.failoverProxy
+ ) {
+ let { type, host, port, username, password, proxyDNS, failoverTimeout } =
+ expectedProxyInfo;
+ equal(proxyUsed.host, host, `Expected proxy host to be ${host}`);
+ equal(proxyUsed.port, port, `Expected proxy port to be ${port}`);
+ equal(proxyUsed.type, type, `Expected proxy type to be ${type}`);
+ // May be null or undefined depending on use of newProxyInfoWithAuth or newProxyInfo
+ equal(
+ proxyUsed.username || "",
+ username || "",
+ `Expected proxy username to be ${username}`
+ );
+ equal(
+ proxyUsed.password || "",
+ password || "",
+ `Expected proxy password to be ${password}`
+ );
+ equal(
+ proxyUsed.flags,
+ proxyDNS == undefined ? 0 : proxyDNS,
+ `Expected proxyDNS to be ${proxyDNS}`
+ );
+ // Default timeout is 10
+ equal(
+ proxyUsed.failoverTimeout,
+ failoverTimeout || 10,
+ `Expected failoverTimeout to be ${failoverTimeout}`
+ );
+ expectedProxyInfo = expectedProxyInfo.failoverProxy;
+ }
+ }
+}
+
+add_task(async function test_proxyInfo_results() {
+ let tests = [
+ {
+ proxy: 5,
+ expected: {
+ error: "ProxyInfoData: proxyData must be an object or array of objects",
+ },
+ },
+ {
+ proxy: "INVALID",
+ expected: {
+ error: "ProxyInfoData: proxyData must be an object or array of objects",
+ },
+ },
+ {
+ proxy: {
+ type: "socks",
+ },
+ expected: {
+ error: 'ProxyInfoData: Invalid proxy server host: "undefined"',
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "pptp",
+ host: "foo.bar",
+ port: 1080,
+ username: "mungosantamaria",
+ password: "pass123",
+ proxyDNS: true,
+ failoverTimeout: 3,
+ },
+ {
+ type: "http",
+ host: "192.168.1.1",
+ port: 1128,
+ username: "mungosantamaria",
+ password: "word321",
+ },
+ ],
+ expected: {
+ error: 'ProxyInfoData: Invalid proxy server type: "pptp"',
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "http",
+ host: "foo.bar",
+ port: 65536,
+ username: "mungosantamaria",
+ password: "pass123",
+ proxyDNS: true,
+ failoverTimeout: 3,
+ },
+ {
+ type: "http",
+ host: "192.168.1.1",
+ port: 3128,
+ username: "mungosantamaria",
+ password: "word321",
+ },
+ ],
+ expected: {
+ error:
+ "ProxyInfoData: Proxy server port 65536 outside range 1 to 65535",
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "http",
+ host: "foo.bar",
+ port: 3128,
+ proxyAuthorizationHeader: "test",
+ },
+ ],
+ expected: {
+ error: 'ProxyInfoData: ProxyAuthorizationHeader requires type "https"',
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "http",
+ host: "foo.bar",
+ port: 3128,
+ connectionIsolationKey: 1234,
+ },
+ ],
+ expected: {
+ error: 'ProxyInfoData: Invalid proxy connection isolation key: "1234"',
+ },
+ },
+ {
+ proxy: [{ type: "direct" }],
+ expected: {
+ proxyInfo: null,
+ },
+ },
+ {
+ proxy: {
+ host: "1.2.3.4",
+ port: "8080",
+ type: "http",
+ failoverProxy: null,
+ },
+ expected: {
+ proxyInfo: {
+ host: "1.2.3.4",
+ port: "8080",
+ type: "http",
+ failoverProxy: null,
+ },
+ },
+ },
+ {
+ uri: "ftp://mozilla.org",
+ proxy: {
+ host: "1.2.3.4",
+ port: "8180",
+ type: "http",
+ failoverProxy: null,
+ },
+ expected: {
+ proxyInfo: {
+ host: "1.2.3.4",
+ port: "8180",
+ type: "http",
+ failoverProxy: null,
+ },
+ },
+ },
+ {
+ proxy: {
+ host: "2.3.4.5",
+ port: "8181",
+ type: "http",
+ failoverProxy: null,
+ },
+ expected: {
+ proxyInfo: {
+ host: "2.3.4.5",
+ port: "8181",
+ type: "http",
+ failoverProxy: null,
+ },
+ },
+ },
+ {
+ proxy: {
+ host: "1.2.3.4",
+ port: "8080",
+ type: "http",
+ failoverProxy: {
+ host: "4.4.4.4",
+ port: "9000",
+ type: "socks",
+ failoverProxy: {
+ type: "direct",
+ host: null,
+ port: -1,
+ },
+ },
+ },
+ expected: {
+ proxyInfo: {
+ host: "1.2.3.4",
+ port: "8080",
+ type: "http",
+ failoverProxy: {
+ host: "4.4.4.4",
+ port: "9000",
+ type: "socks",
+ failoverProxy: {
+ type: "direct",
+ host: null,
+ port: -1,
+ },
+ },
+ },
+ },
+ },
+ {
+ proxy: [{ type: "http", host: "foo.bar", port: 3128 }],
+ expected: {
+ proxyInfo: {
+ host: "foo.bar",
+ port: "3128",
+ type: "http",
+ },
+ },
+ },
+ {
+ proxy: {
+ host: "foo.bar",
+ port: "1080",
+ type: "socks",
+ },
+ expected: {
+ proxyInfo: {
+ host: "foo.bar",
+ port: "1080",
+ type: "socks",
+ },
+ },
+ },
+ {
+ proxy: {
+ host: "foo.bar",
+ port: "1080",
+ type: "socks4",
+ },
+ expected: {
+ proxyInfo: {
+ host: "foo.bar",
+ port: "1080",
+ type: "socks4",
+ },
+ },
+ },
+ {
+ proxy: [{ type: "https", host: "foo.bar", port: 3128 }],
+ expected: {
+ proxyInfo: {
+ host: "foo.bar",
+ port: "3128",
+ type: "https",
+ },
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "socks",
+ host: "foo.bar",
+ port: 1080,
+ username: "mungo",
+ password: "santamaria123",
+ proxyDNS: true,
+ failoverTimeout: 5,
+ },
+ ],
+ expected: {
+ proxyInfo: {
+ type: "socks",
+ host: "foo.bar",
+ port: 1080,
+ username: "mungo",
+ password: "santamaria123",
+ failoverTimeout: 5,
+ failoverProxy: null,
+ proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST,
+ },
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "socks",
+ host: "foo.bar",
+ port: 1080,
+ username: "johnsmith",
+ password: "pass123",
+ proxyDNS: true,
+ failoverTimeout: 3,
+ },
+ { type: "http", host: "192.168.1.1", port: 3128 },
+ { type: "https", host: "192.168.1.2", port: 1121, failoverTimeout: 1 },
+ {
+ type: "socks",
+ host: "192.168.1.3",
+ port: 1999,
+ proxyDNS: true,
+ username: "mungosantamaria",
+ password: "foobar",
+ },
+ ],
+ expected: {
+ proxyInfo: {
+ type: "socks",
+ host: "foo.bar",
+ port: 1080,
+ proxyDNS: true,
+ username: "johnsmith",
+ password: "pass123",
+ failoverTimeout: 3,
+ failoverProxy: {
+ host: "192.168.1.1",
+ port: 3128,
+ type: "http",
+ failoverProxy: {
+ host: "192.168.1.2",
+ port: 1121,
+ type: "https",
+ failoverTimeout: 1,
+ failoverProxy: {
+ host: "192.168.1.3",
+ port: 1999,
+ type: "socks",
+ proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST,
+ username: "mungosantamaria",
+ password: "foobar",
+ failoverProxy: {
+ type: "direct",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "https",
+ host: "foo.bar",
+ port: 3128,
+ proxyAuthorizationHeader: "test",
+ connectionIsolationKey: "key",
+ },
+ ],
+ expected: {
+ proxyInfo: {
+ host: "foo.bar",
+ port: "3128",
+ type: "https",
+ proxyAuthorizationHeader: "test",
+ connectionIsolationKey: "key",
+ },
+ },
+ },
+ ];
+ for (let test of tests) {
+ await setupProxyResult(test.proxy);
+ if (!test.uri) {
+ test.uri = "http://www.mozilla.org/";
+ }
+ await testProxyResolution(test);
+ }
+});
+
+add_task(async function shutdown() {
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js b/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js
new file mode 100644
index 0000000000..8cc46d45e7
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js
@@ -0,0 +1,298 @@
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gProxyService",
+ "@mozilla.org/network/protocol-proxy-service;1",
+ "nsIProtocolProxyService"
+);
+
+const TRANSPARENT_PROXY_RESOLVES_HOST =
+ Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST;
+
+function getProxyInfo(url = "http://www.mozilla.org/") {
+ return new Promise((resolve, reject) => {
+ let channel = NetUtil.newChannel({
+ uri: url,
+ loadUsingSystemPrincipal: true,
+ });
+
+ gProxyService.asyncResolve(channel, 0, {
+ onProxyAvailable(req, uri, pi, status) {
+ resolve(pi);
+ },
+ });
+ });
+}
+
+const testData = [
+ {
+ // An ExtensionError is thrown for this, but we are unable to catch it as we
+ // do with the PAC script api. In this case, we expect null for proxyInfo.
+ proxyInfo: "not_defined",
+ expected: {
+ proxyInfo: null,
+ },
+ },
+ {
+ proxyInfo: 1,
+ expected: {
+ error: {
+ message:
+ "ProxyInfoData: proxyData must be an object or array of objects",
+ },
+ },
+ },
+ {
+ proxyInfo: [
+ {
+ type: "socks",
+ host: "foo.bar",
+ port: 1080,
+ username: "johnsmith",
+ password: "pass123",
+ proxyDNS: true,
+ failoverTimeout: 3,
+ },
+ { type: "http", host: "192.168.1.1", port: 3128 },
+ { type: "https", host: "192.168.1.2", port: 1121, failoverTimeout: 1 },
+ {
+ type: "socks",
+ host: "192.168.1.3",
+ port: 1999,
+ proxyDNS: true,
+ username: "mungosantamaria",
+ password: "foobar",
+ },
+ { type: "direct" },
+ ],
+ expected: {
+ proxyInfo: {
+ type: "socks",
+ host: "foo.bar",
+ port: 1080,
+ proxyDNS: true,
+ username: "johnsmith",
+ password: "pass123",
+ failoverTimeout: 3,
+ failoverProxy: {
+ host: "192.168.1.1",
+ port: 3128,
+ type: "http",
+ failoverProxy: {
+ host: "192.168.1.2",
+ port: 1121,
+ type: "https",
+ failoverTimeout: 1,
+ failoverProxy: {
+ host: "192.168.1.3",
+ port: 1999,
+ type: "socks",
+ proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST,
+ username: "mungosantamaria",
+ password: "foobar",
+ failoverProxy: {
+ type: "direct",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+];
+
+add_task(async function test_proxy_listener() {
+ let extensionData = {
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background() {
+ // Some tests generate multiple errors, we'll just rely on the first.
+ let seenError = false;
+ let proxyInfo;
+ browser.proxy.onError.addListener(error => {
+ if (!seenError) {
+ browser.test.sendMessage("proxy-error-received", error);
+ seenError = true;
+ }
+ });
+
+ browser.proxy.onRequest.addListener(
+ details => {
+ browser.test.log(`onRequest ${JSON.stringify(details)}`);
+ if (proxyInfo == "not_defined") {
+ return not_defined; // eslint-disable-line no-undef
+ }
+ return proxyInfo;
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.test.onMessage.addListener((message, data) => {
+ if (message === "set-proxy") {
+ seenError = false;
+ proxyInfo = data.proxyInfo;
+ }
+ });
+
+ browser.test.sendMessage("ready");
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ for (let test of testData) {
+ extension.sendMessage("set-proxy", test);
+ let testError = test.expected.error;
+ let errorWait = testError && extension.awaitMessage("proxy-error-received");
+
+ let proxyInfo = await getProxyInfo();
+ let expectedProxyInfo = test.expected.proxyInfo;
+
+ if (testError) {
+ info("waiting for error data");
+ let error = await errorWait;
+ equal(error.message, testError.message, "Correct error message received");
+ equal(proxyInfo, null, "no proxyInfo received");
+ } else if (expectedProxyInfo === null) {
+ equal(proxyInfo, null, "no proxyInfo received");
+ } else {
+ for (
+ let proxyUsed = proxyInfo;
+ proxyUsed;
+ proxyUsed = proxyUsed.failoverProxy
+ ) {
+ let {
+ type,
+ host,
+ port,
+ username,
+ password,
+ proxyDNS,
+ failoverTimeout,
+ } = expectedProxyInfo;
+ equal(proxyUsed.host, host, `Expected proxy host to be ${host}`);
+ equal(proxyUsed.port, port || -1, `Expected proxy port to be ${port}`);
+ equal(proxyUsed.type, type, `Expected proxy type to be ${type}`);
+ // May be null or undefined depending on use of newProxyInfoWithAuth or newProxyInfo
+ equal(
+ proxyUsed.username || "",
+ username || "",
+ `Expected proxy username to be ${username}`
+ );
+ equal(
+ proxyUsed.password || "",
+ password || "",
+ `Expected proxy password to be ${password}`
+ );
+ equal(
+ proxyUsed.flags,
+ proxyDNS == undefined ? 0 : proxyDNS,
+ `Expected proxyDNS to be ${proxyDNS}`
+ );
+ // Default timeout is 10
+ equal(
+ proxyUsed.failoverTimeout,
+ failoverTimeout || 10,
+ `Expected failoverTimeout to be ${failoverTimeout}`
+ );
+ expectedProxyInfo = expectedProxyInfo.failoverProxy;
+ }
+ ok(!expectedProxyInfo, "no left over failoverProxy");
+ }
+ }
+
+ await extension.unload();
+});
+
+async function getExtension(expectedProxyInfo) {
+ function background(proxyInfo) {
+ browser.test.log(
+ `testing proxy.onRequest with proxyInfo = ${JSON.stringify(proxyInfo)}`
+ );
+ browser.proxy.onRequest.addListener(
+ details => {
+ return proxyInfo;
+ },
+ { urls: ["<all_urls>"] }
+ );
+ }
+ let extensionData = {
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background: `(${background})(${JSON.stringify(expectedProxyInfo)})`,
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ return extension;
+}
+
+add_task(async function test_passthrough() {
+ let ext1 = await getExtension(null);
+ let ext2 = await getExtension({ host: "1.2.3.4", port: 8888, type: "https" });
+
+ // Also use a restricted url to test the ability to proxy those.
+ let proxyInfo = await getProxyInfo("https://addons.mozilla.org/");
+
+ equal(proxyInfo.host, "1.2.3.4", `second extension won`);
+ equal(proxyInfo.port, "8888", `second extension won`);
+ equal(proxyInfo.type, "https", `second extension won`);
+
+ await ext2.unload();
+
+ proxyInfo = await getProxyInfo();
+ equal(proxyInfo, null, `expected no proxy`);
+ await ext1.unload();
+});
+
+add_task(async function test_ftp_disabled() {
+ let extension = await getExtension({
+ host: "1.2.3.4",
+ port: 8888,
+ type: "http",
+ });
+
+ let proxyInfo = await getProxyInfo("ftp://somewhere.mozilla.org/");
+
+ equal(
+ proxyInfo,
+ null,
+ `proxy of ftp request is not available when ftp is disabled`
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_ws() {
+ let proxyRequestCount = 0;
+ let proxy = createHttpServer();
+ proxy.registerPathHandler("CONNECT", (request, response) => {
+ response.setStatusLine(request.httpVersion, 404, "Proxy not found");
+ ++proxyRequestCount;
+ });
+
+ let extension = await getExtension({
+ host: proxy.identity.primaryHost,
+ port: proxy.identity.primaryPort,
+ type: "http",
+ });
+
+ // We need a page to use the WebSocket constructor, so let's use an extension.
+ let dummy = ExtensionTestUtils.loadExtension({
+ background() {
+ // The connection will not be upgraded to WebSocket, so it will close.
+ let ws = new WebSocket("wss://example.net/");
+ ws.onclose = () => browser.test.sendMessage("websocket_closed");
+ },
+ });
+ await dummy.startup();
+ await dummy.awaitMessage("websocket_closed");
+ await dummy.unload();
+
+ equal(proxyRequestCount, 1, "Expected one proxy request");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js b/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js
new file mode 100644
index 0000000000..5dea560e02
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js
@@ -0,0 +1,43 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_userContextId_proxy_onRequest() {
+ // This extension will succeed if it gets a request
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background() {
+ browser.proxy.onRequest.addListener(
+ async details => {
+ if (details.url != "http://example.com/dummy") {
+ return;
+ }
+ browser.test.assertEq(
+ details.cookieStoreId,
+ "firefox-container-2",
+ "cookieStoreId is set"
+ );
+ browser.test.notifyPass("proxy.onRequest");
+ },
+ { urls: ["<all_urls>"] }
+ );
+ },
+ });
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy",
+ { userContextId: 2 }
+ );
+ await extension.awaitFinish("proxy.onRequest");
+ await extension.unload();
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_resistfingerprinting_exempt.js b/toolkit/components/extensions/test/xpcshell/test_resistfingerprinting_exempt.js
new file mode 100644
index 0000000000..6440a470bf
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_resistfingerprinting_exempt.js
@@ -0,0 +1,40 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+async function queryAppName() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("result", { appName: navigator.appName });
+ },
+ });
+ await extension.startup();
+ let result = await extension.awaitMessage("result");
+ await extension.unload();
+ return result.appName;
+}
+
+const APPNAME_OVERRIDE = "MyTestAppName";
+
+add_task(
+ {
+ pref_set: [["general.appname.override", APPNAME_OVERRIDE]],
+ },
+ async function test_appName_normal() {
+ let appName = await queryAppName();
+ Assert.equal(appName, APPNAME_OVERRIDE);
+ }
+);
+
+add_task(
+ {
+ pref_set: [
+ ["general.appname.override", APPNAME_OVERRIDE],
+ ["privacy.resistFingerprinting", true],
+ ],
+ },
+ async function test_appName_resistFingerprinting() {
+ let appName = await queryAppName();
+ Assert.equal(appName, APPNAME_OVERRIDE);
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_site_permissions.js b/toolkit/components/extensions/test/xpcshell/test_site_permissions.js
new file mode 100644
index 0000000000..652c9ae3ac
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_site_permissions.js
@@ -0,0 +1,385 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// TODO(Bug 1789718): adapt to synthetic addon type implemented by the SitePermAddonProvider
+// or remove if redundant, after the deprecated XPIProvider-based implementation is also removed.
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+const l10n = new Localization([
+ "toolkit/global/extensions.ftl",
+ "toolkit/global/extensionPermissions.ftl",
+ "branding/brand.ftl",
+]);
+// Localization resources need to be first iterated outside a test
+l10n.formatValue("webext-perms-sideload-text");
+
+// Lazily import ExtensionParent to allow AddonTestUtils.createAppInfo to
+// override Services.appinfo.
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+});
+
+async function _test_manifest(manifest, expectedError) {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ let normalized = await ExtensionTestUtils.normalizeManifest(
+ manifest,
+ "manifest.WebExtensionSitePermissionsManifest"
+ );
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ if (expectedError) {
+ ok(
+ normalized.error.includes(expectedError),
+ `The manifest error ${JSON.stringify(
+ normalized.error
+ )} must contain ${JSON.stringify(expectedError)}`
+ );
+ } else {
+ equal(normalized.error, undefined, "Should not have an error");
+ }
+ equal(normalized.errors.length, 0, "Should have no warning");
+}
+
+add_setup(async () => {
+ // Telemetry test setup needed to ensure that the builtin events are defined
+ // and they can be collected and verified.
+ await TelemetryController.testSetup();
+
+ // This is actually only needed on Android, because it does not properly support unified telemetry
+ // and so, if not enabled explicitly here, it would make these tests to fail when running on
+ // release builds.
+ const oldCanRecordBase = Services.telemetry.canRecordBase;
+ Services.telemetry.canRecordBase = true;
+ registerCleanupFunction(() => {
+ Services.telemetry.canRecordBase = oldCanRecordBase;
+ });
+});
+
+add_task(async function test_manifest_site_permissions() {
+ await _test_manifest({
+ site_permissions: ["midi"],
+ install_origins: ["http://example.com"],
+ });
+ await _test_manifest({
+ site_permissions: ["midi-sysex"],
+ install_origins: ["http://example.com"],
+ });
+ await _test_manifest(
+ {
+ site_permissions: ["unknown_site_permission"],
+ install_origins: ["http://example.com"],
+ },
+ `Error processing site_permissions.0: Invalid enumeration value "unknown_site_permission"`
+ );
+ await _test_manifest(
+ {
+ site_permissions: ["unknown_site_permission"],
+ install_origins: [],
+ },
+ `Error processing install_origins: Array requires at least 1 items;`
+ );
+ await _test_manifest(
+ {
+ site_permissions: ["unknown_site_permission"],
+ },
+ `Property "install_origins" is required`
+ );
+ await _test_manifest(
+ {
+ install_origins: ["http://example.com"],
+ },
+ `Property "site_permissions" is required`
+ );
+ // test any extra manifest entries not part of a site permissions addon will cause an error.
+ await _test_manifest(
+ {
+ site_permissions: ["midi"],
+ install_origins: ["http://example.com"],
+ permissions: ["webRequest"],
+ },
+ `Unexpected property`
+ );
+});
+
+add_task(async function test_sitepermission_telemetry() {
+ await AddonTestUtils.promiseStartupManager();
+
+ Services.telemetry.clearEvents();
+
+ const addon_id = "webmidi@test";
+ const origin = "https://example.com";
+ const permName = "midi";
+
+ let site_permission = {
+ "manifest.json": {
+ name: "test Site Permission",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: { id: addon_id },
+ },
+ install_origins: [origin],
+ site_permissions: [permName],
+ },
+ };
+
+ let [, { addon }] = await Promise.all([
+ TestUtils.topicObserved("webextension-sitepermissions-startup"),
+ AddonTestUtils.promiseInstallXPI(site_permission),
+ ]);
+
+ await addon.uninstall();
+
+ await TelemetryTestUtils.assertEvents(
+ [
+ [
+ "addonsManager",
+ "install",
+ "siteperm_deprecated",
+ /.*/,
+ {
+ step: "started",
+ addon_id,
+ },
+ ],
+ [
+ "addonsManager",
+ "install",
+ "siteperm_deprecated",
+ /.*/,
+ {
+ step: "completed",
+ addon_id,
+ },
+ ],
+ ["addonsManager", "uninstall", "siteperm_deprecated", addon_id],
+ ],
+ {
+ category: "addonsManager",
+ method: /^install|uninstall$/,
+ }
+ );
+
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+async function _test_ext_site_permissions(site_permissions, install_origins) {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ install_origins,
+ site_permissions,
+ },
+ });
+ await extension.startup();
+ await extension.unload();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+}
+
+add_task(async function test_ext_site_permissions() {
+ await _test_ext_site_permissions(["midi"], ["http://example.com"]);
+
+ await _test_ext_site_permissions(
+ ["midi"],
+ ["http://example.com", "http://foo.com"]
+ ).catch(e => {
+ Assert.ok(
+ e.message.includes(
+ "Error processing install_origins: Array requires at most 1 items; you have 2"
+ ),
+ "Site permissions can only contain one install origin: "
+ );
+ });
+});
+
+add_task(async function test_sitepermission_type() {
+ await AddonTestUtils.promiseStartupManager();
+
+ // Test more than one perm to make sure both are added.
+ // While this is allowed, midi-sysex overrides.
+ let perms = ["midi", "midi-sysex"];
+ let id = "@test-permission";
+ let origin = "http://example.com";
+ let uri = Services.io.newURI(origin);
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+
+ // give the site some other permission (geo)
+ Services.perms.addFromPrincipal(
+ principal,
+ "geo",
+ Services.perms.ALLOW_ACTION,
+ Services.perms.EXPIRE_NEVER
+ );
+
+ let assertGeo = () => {
+ Assert.equal(
+ Services.perms.testExactPermissionFromPrincipal(principal, "geo"),
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ "site still has geo permission"
+ );
+ };
+
+ let checkPerms = (perms, action, msg) => {
+ for (let permName of perms) {
+ let permission = Services.perms.testExactPermissionFromPrincipal(
+ principal,
+ permName
+ );
+ Assert.equal(permission, action, `${permName}: ${msg}`);
+ }
+ };
+
+ checkPerms(
+ perms,
+ Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "no permission for site"
+ );
+
+ let site_permission = {
+ "manifest.json": {
+ name: "test Site Permission",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id,
+ },
+ },
+ install_origins: [origin],
+ site_permissions: perms,
+ },
+ };
+
+ let [, { addon }] = await Promise.all([
+ TestUtils.topicObserved("webextension-sitepermissions-startup"),
+ AddonTestUtils.promiseInstallXPI(site_permission),
+ ]);
+
+ checkPerms(
+ perms,
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ "extension enabled permission for site"
+ );
+ assertGeo();
+
+ // Test the permission is retained on restart.
+ await AddonTestUtils.promiseRestartManager();
+ addon = await AddonManager.getAddonByID(id);
+
+ checkPerms(
+ perms,
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ "extension enabled permission for site"
+ );
+ assertGeo();
+
+ // Test that a removed permission is added on restart
+ Services.perms.removeFromPrincipal(principal, perms[0]);
+ await AddonTestUtils.promiseRestartManager();
+ addon = await AddonManager.getAddonByID(id);
+
+ checkPerms(
+ perms,
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ "extension enabled permission for site"
+ );
+ assertGeo();
+
+ // Test that a changed permission is not changed on restart
+ Services.perms.addFromPrincipal(
+ principal,
+ perms[0],
+ Services.perms.DENY_ACTION,
+ Services.perms.EXPIRE_NEVER
+ );
+
+ await AddonTestUtils.promiseRestartManager();
+ addon = await AddonManager.getAddonByID(id);
+
+ checkPerms(
+ [perms[0]],
+ Ci.nsIPermissionManager.DENY_ACTION,
+ "extension enabled permission for site"
+ );
+ checkPerms(
+ [perms[1]],
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ "extension enabled permission for site"
+ );
+ assertGeo();
+
+ // Test permission removal when addon disabled
+ await addon.disable();
+
+ checkPerms(
+ perms,
+ Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "no permission for site"
+ );
+ assertGeo();
+
+ // Enabling an addon will always force ALLOW_ACTION
+ await addon.enable();
+
+ checkPerms(
+ perms,
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ "extension enabled permission for site"
+ );
+ assertGeo();
+
+ // Test permission removal when addon uninstalled
+ await addon.uninstall();
+
+ checkPerms(
+ perms,
+ Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "no permission for site"
+ );
+ assertGeo();
+});
+
+add_task(async function test_site_permissions_have_localization_strings() {
+ await ExtensionParent.apiManager.lazyInit();
+ const SCHEMA_SITE_PERMISSIONS = Schemas.getPermissionNames([
+ "SitePermission",
+ ]);
+ ok(SCHEMA_SITE_PERMISSIONS.length, "we have site permissions");
+
+ for (const perm of SCHEMA_SITE_PERMISSIONS) {
+ const l10nId = `webext-site-perms-${perm}`;
+ try {
+ const str = await l10n.formatValue(l10nId);
+
+ ok(str.length, `Found localization string for '${perm}' site permission`);
+ } catch (e) {
+ ok(false, `Site permission missing '${perm}'`);
+ }
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js
new file mode 100644
index 0000000000..43e2d11872
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js
@@ -0,0 +1,79 @@
+"use strict";
+
+var { WebRequest } = ChromeUtils.importESModule(
+ "resource://gre/modules/WebRequest.sys.mjs"
+);
+var { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function setup() {
+ // When WebRequest.jsm is used directly instead of through ext-webRequest.js,
+ // ExtensionParent.apiManager is not automatically initialized. Do it here.
+ await ExtensionParent.apiManager.lazyInit();
+});
+
+add_task(async function test_ancestors_exist() {
+ let deferred = PromiseUtils.defer();
+ function onBeforeRequest(details) {
+ info(`onBeforeRequest ${details.url}`);
+ ok(
+ typeof details.frameAncestors === "object",
+ `ancestors exists [${typeof details.frameAncestors}]`
+ );
+ deferred.resolve();
+ }
+
+ WebRequest.onBeforeRequest.addListener(
+ onBeforeRequest,
+ { urls: new MatchPatternSet(["http://example.com/*"]) },
+ ["blocking"]
+ );
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+ await deferred.promise;
+ await contentPage.close();
+
+ WebRequest.onBeforeRequest.removeListener(onBeforeRequest);
+});
+
+add_task(async function test_ancestors_null() {
+ let deferred = PromiseUtils.defer();
+ function onBeforeRequest(details) {
+ info(`onBeforeRequest ${details.url}`);
+ ok(details.frameAncestors === undefined, "ancestors do not exist");
+ deferred.resolve();
+ }
+
+ WebRequest.onBeforeRequest.addListener(onBeforeRequest, null, ["blocking"]);
+
+ function fetch(url) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.mozBackgroundRequest = true;
+ xhr.open("GET", url);
+ xhr.onload = () => {
+ resolve(xhr.responseText);
+ };
+ xhr.onerror = () => {
+ reject(xhr.status);
+ };
+ // use a different contextId to avoid auth cache.
+ xhr.setOriginAttributes({ userContextId: 1 });
+ xhr.send();
+ });
+ }
+
+ await fetch("http://example.com/data/file_sample.html");
+ await deferred.promise;
+
+ WebRequest.onBeforeRequest.removeListener(onBeforeRequest);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js
new file mode 100644
index 0000000000..53ed465786
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js
@@ -0,0 +1,102 @@
+"use strict";
+
+var { WebRequest } = ChromeUtils.importESModule(
+ "resource://gre/modules/WebRequest.sys.mjs"
+);
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerPathHandler("/", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ if (request.hasHeader("Cookie")) {
+ let value = request.getHeader("Cookie");
+ if (value == "blinky=1") {
+ response.setHeader("Set-Cookie", "dinky=1", false);
+ }
+ response.write("cookie-present");
+ } else {
+ response.setHeader("Set-Cookie", "foopy=1", false);
+ response.write("cookie-not-present");
+ }
+});
+
+const URL = "http://example.com/";
+
+var countBefore = 0;
+var countAfter = 0;
+
+function onBeforeSendHeaders(details) {
+ if (details.url != URL) {
+ return undefined;
+ }
+
+ countBefore++;
+
+ info(`onBeforeSendHeaders ${details.url}`);
+ let found = false;
+ let headers = [];
+ for (let { name, value } of details.requestHeaders) {
+ info(`Saw header ${name} '${value}'`);
+ if (name == "Cookie") {
+ equal(value, "foopy=1", "Cookie is correct");
+ headers.push({ name, value: "blinky=1" });
+ found = true;
+ } else {
+ headers.push({ name, value });
+ }
+ }
+ ok(found, "Saw cookie header");
+ equal(countBefore, 1, "onBeforeSendHeaders hit once");
+
+ return { requestHeaders: headers };
+}
+
+function onResponseStarted(details) {
+ if (details.url != URL) {
+ return;
+ }
+
+ countAfter++;
+
+ info(`onResponseStarted ${details.url}`);
+ let found = false;
+ for (let { name, value } of details.responseHeaders) {
+ info(`Saw header ${name} '${value}'`);
+ if (name == "set-cookie") {
+ equal(value, "dinky=1", "Cookie is correct");
+ found = true;
+ }
+ }
+ ok(found, "Saw cookie header");
+ equal(countAfter, 1, "onResponseStarted hit once");
+}
+
+add_task(async function setup() {
+ // When WebRequest.jsm is used directly instead of through ext-webRequest.js,
+ // ExtensionParent.apiManager is not automatically initialized. Do it here.
+ await ExtensionParent.apiManager.lazyInit();
+});
+
+add_task(async function filter_urls() {
+ // First load the URL so that we set cookie foopy=1.
+ let contentPage = await ExtensionTestUtils.loadContentPage(URL);
+ await contentPage.close();
+
+ // Now load with WebRequest set up.
+ WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, null, [
+ "blocking",
+ "requestHeaders",
+ ]);
+ WebRequest.onResponseStarted.addListener(onResponseStarted, null, [
+ "responseHeaders",
+ ]);
+
+ contentPage = await ExtensionTestUtils.loadContentPage(URL);
+ await contentPage.close();
+
+ WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders);
+ WebRequest.onResponseStarted.removeListener(onResponseStarted);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js
new file mode 100644
index 0000000000..a7157f19a4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js
@@ -0,0 +1,182 @@
+"use strict";
+
+var { WebRequest } = ChromeUtils.importESModule(
+ "resource://gre/modules/WebRequest.sys.mjs"
+);
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE = "http://example.com/data/";
+const URL = BASE + "/file_WebRequest_page2.html";
+
+var requested = [];
+
+function onBeforeRequest(details) {
+ info(`onBeforeRequest ${details.url}`);
+ if (details.url.startsWith(BASE)) {
+ requested.push(details.url);
+ }
+}
+
+var sendHeaders = [];
+
+function onBeforeSendHeaders(details) {
+ info(`onBeforeSendHeaders ${details.url}`);
+ if (details.url.startsWith(BASE)) {
+ sendHeaders.push(details.url);
+ }
+}
+
+var completed = [];
+
+function onResponseStarted(details) {
+ if (details.url.startsWith(BASE)) {
+ completed.push(details.url);
+ }
+}
+
+const expected_urls = [
+ BASE + "/file_style_good.css",
+ BASE + "/file_style_bad.css",
+ BASE + "/file_style_redirect.css",
+];
+
+function resetExpectations() {
+ requested.length = 0;
+ sendHeaders.length = 0;
+ completed.length = 0;
+}
+
+function removeDupes(list) {
+ let j = 0;
+ for (let i = 1; i < list.length; i++) {
+ if (list[i] != list[j]) {
+ j++;
+ if (i != j) {
+ list[j] = list[i];
+ }
+ }
+ }
+ list.length = j + 1;
+}
+
+function compareLists(list1, list2, kind) {
+ list1.sort();
+ removeDupes(list1);
+ list2.sort();
+ removeDupes(list2);
+ equal(String(list1), String(list2), `${kind} URLs correct`);
+}
+
+async function openAndCloseContentPage(url) {
+ let contentPage = await ExtensionTestUtils.loadContentPage(URL);
+ // Clear the sheet cache so that it doesn't interact with following tests: A
+ // stylesheet with the same URI loaded from the same origin doesn't otherwise
+ // guarantee that onBeforeRequest and so on happen, because it may not need
+ // to go through necko at all.
+ await contentPage.spawn([], () =>
+ content.windowUtils.clearSharedStyleSheetCache()
+ );
+ await contentPage.close();
+}
+
+add_task(async function setup() {
+ // Disable rcwn to make cache behavior deterministic.
+ Services.prefs.setBoolPref("network.http.rcwn.enabled", false);
+
+ // When WebRequest.jsm is used directly instead of through ext-webRequest.js,
+ // ExtensionParent.apiManager is not automatically initialized. Do it here.
+ await ExtensionParent.apiManager.lazyInit();
+});
+
+add_task(async function filter_urls() {
+ let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]) };
+
+ WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]);
+ WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [
+ "blocking",
+ ]);
+ WebRequest.onResponseStarted.addListener(onResponseStarted, filter);
+
+ await openAndCloseContentPage(URL);
+
+ compareLists(requested, expected_urls, "requested");
+ compareLists(sendHeaders, expected_urls, "sendHeaders");
+ compareLists(completed, expected_urls, "completed");
+
+ WebRequest.onBeforeRequest.removeListener(onBeforeRequest);
+ WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders);
+ WebRequest.onResponseStarted.removeListener(onResponseStarted);
+});
+
+add_task(async function filter_types() {
+ resetExpectations();
+ let filter = { types: ["stylesheet"] };
+
+ WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]);
+ WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [
+ "blocking",
+ ]);
+ WebRequest.onResponseStarted.addListener(onResponseStarted, filter);
+
+ await openAndCloseContentPage(URL);
+
+ compareLists(requested, expected_urls, "requested");
+ compareLists(sendHeaders, expected_urls, "sendHeaders");
+ compareLists(completed, expected_urls, "completed");
+
+ WebRequest.onBeforeRequest.removeListener(onBeforeRequest);
+ WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders);
+ WebRequest.onResponseStarted.removeListener(onResponseStarted);
+});
+
+add_task(async function filter_windowId() {
+ resetExpectations();
+ // Check that adding windowId will exclude non-matching requests.
+ // test_ext_webrequest_filter.html provides coverage for matching requests.
+ let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]), windowId: 0 };
+
+ WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]);
+ WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [
+ "blocking",
+ ]);
+ WebRequest.onResponseStarted.addListener(onResponseStarted, filter);
+
+ await openAndCloseContentPage(URL);
+
+ compareLists(requested, [], "requested");
+ compareLists(sendHeaders, [], "sendHeaders");
+ compareLists(completed, [], "completed");
+
+ WebRequest.onBeforeRequest.removeListener(onBeforeRequest);
+ WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders);
+ WebRequest.onResponseStarted.removeListener(onResponseStarted);
+});
+
+add_task(async function filter_tabId() {
+ resetExpectations();
+ // Check that adding tabId will exclude non-matching requests.
+ // test_ext_webrequest_filter.html provides coverage for matching requests.
+ let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]), tabId: 0 };
+
+ WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]);
+ WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [
+ "blocking",
+ ]);
+ WebRequest.onResponseStarted.addListener(onResponseStarted, filter);
+
+ await openAndCloseContentPage(URL);
+
+ compareLists(requested, [], "requested");
+ compareLists(sendHeaders, [], "sendHeaders");
+ compareLists(completed, [], "completed");
+
+ WebRequest.onBeforeRequest.removeListener(onBeforeRequest);
+ WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders);
+ WebRequest.onResponseStarted.removeListener(onResponseStarted);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/.eslintrc.js b/toolkit/components/extensions/test/xpcshell/webidl-api/.eslintrc.js
new file mode 100644
index 0000000000..3622fff4f6
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/webidl-api/.eslintrc.js
@@ -0,0 +1,9 @@
+"use strict";
+
+module.exports = {
+ env: {
+ // The tests in this folder are testing based on WebExtensions, so lets
+ // just define the webextensions environment here.
+ webextensions: true,
+ },
+};
diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/head_webidl_api.js b/toolkit/components/extensions/test/xpcshell/webidl-api/head_webidl_api.js
new file mode 100644
index 0000000000..3e8e094a20
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/webidl-api/head_webidl_api.js
@@ -0,0 +1,306 @@
+/* import-globals-from ../head.js */
+
+/* exported getBackgroundServiceWorkerRegistration, waitForTerminatedWorkers,
+ * runExtensionAPITest */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+add_setup(function checkExtensionsWebIDLEnabled() {
+ equal(
+ AppConstants.MOZ_WEBEXT_WEBIDL_ENABLED,
+ true,
+ "WebExtensions WebIDL bindings build time flag should be enabled"
+ );
+});
+
+function getBackgroundServiceWorkerRegistration(extension) {
+ const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService(
+ Ci.nsIServiceWorkerManager
+ );
+
+ const swRegs = swm.getAllRegistrations();
+ const scope = `moz-extension://${extension.uuid}/`;
+
+ for (let i = 0; i < swRegs.length; i++) {
+ let regInfo = swRegs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo);
+ if (regInfo.scope === scope) {
+ return regInfo;
+ }
+ }
+}
+
+function waitForTerminatedWorkers(swRegInfo) {
+ info(`Wait all ${swRegInfo.scope} workers to be terminated`);
+ return TestUtils.waitForCondition(() => {
+ const { evaluatingWorker, installingWorker, waitingWorker, activeWorker } =
+ swRegInfo;
+ return !(
+ evaluatingWorker ||
+ installingWorker ||
+ waitingWorker ||
+ activeWorker
+ );
+ }, `wait workers for scope ${swRegInfo.scope} to be terminated`);
+}
+
+function unmockHandleAPIRequest(extPage) {
+ return extPage.spawn([], () => {
+ const { ExtensionAPIRequestHandler } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionProcessScript.sys.mjs"
+ );
+
+ // Unmock ExtensionAPIRequestHandler.
+ if (ExtensionAPIRequestHandler._handleAPIRequest_orig) {
+ ExtensionAPIRequestHandler.handleAPIRequest =
+ ExtensionAPIRequestHandler._handleAPIRequest_orig;
+ delete ExtensionAPIRequestHandler._handleAPIRequest_orig;
+ }
+ });
+}
+
+function mockHandleAPIRequest(extPage, mockHandleAPIRequest) {
+ mockHandleAPIRequest =
+ mockHandleAPIRequest ||
+ ((policy, request) => {
+ const ExtError = request.window?.Error || Error;
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR,
+ value: new ExtError(
+ "mockHandleAPIRequest not defined by this test case"
+ ),
+ };
+ });
+
+ return extPage.legacySpawn(
+ [ExtensionTestCommon.serializeFunction(mockHandleAPIRequest)],
+ mockFnText => {
+ const { ExtensionAPIRequestHandler } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionProcessScript.sys.mjs"
+ );
+
+ mockFnText = `(() => {
+ return (${mockFnText});
+ })();`;
+ // eslint-disable-next-line no-eval
+ const mockFn = eval(mockFnText);
+
+ // Mock ExtensionAPIRequestHandler.
+ if (!ExtensionAPIRequestHandler._handleAPIRequest_orig) {
+ ExtensionAPIRequestHandler._handleAPIRequest_orig =
+ ExtensionAPIRequestHandler.handleAPIRequest;
+ }
+
+ ExtensionAPIRequestHandler.handleAPIRequest = function (policy, request) {
+ if (request.apiNamespace === "test") {
+ return this._handleAPIRequest_orig(policy, request);
+ }
+ return mockFn.call(this, policy, request);
+ };
+ }
+ );
+}
+
+/**
+ * An helper function used to run unit test that are meant to test the
+ * Extension API webidl bindings helpers shared by all the webextensions
+ * API namespaces.
+ *
+ * @param {string} testDescription
+ * Brief description of the test.
+ * @param {object} [options]
+ * @param {Function} options.backgroundScript
+ * Test function running in the extension global. This function
+ * does receive a parameter of type object with the following
+ * properties:
+ * - testLog(message): log a message on the terminal
+ * - testAsserts:
+ * - isErrorInstance(err): throw if err is not an Error instance
+ * - isInstanceOf(value, globalContructorName): throws if value
+ * is not an instance of global[globalConstructorName]
+ * - equal(val, exp, msg): throw an error including msg if
+ * val is not strictly equal to exp.
+ * @param {Function} options.assertResults
+ * Function to be provided to assert the result returned by
+ * `backgroundScript`, or assert the error if it did throw.
+ * This function does receive a parameter of type object with
+ * the following properties:
+ * - testResult: the result returned (and resolved if the return
+ * value was a promise) from the call to `backgroundScript`
+ * - testError: the error raised (or rejected if the return value
+ * value was a promise) from the call to `backgroundScript`
+ * - extension: the extension wrapper created by this helper.
+ * @param {Function} options.mockAPIRequestHandler
+ * Function to be used to mock mozIExtensionAPIRequestHandler.handleAPIRequest
+ * for the purpose of the test.
+ * This function received the same parameter that are listed in the idl
+ * definition (mozIExtensionAPIRequestHandling.webidl).
+ * @param {string} [options.extensionId]
+ * Optional extension id for the test extension.
+ */
+async function runExtensionAPITest(
+ testDescription,
+ {
+ backgroundScript,
+ assertResults,
+ mockAPIRequestHandler,
+ extensionId = "test-ext-api-request-forward@mochitest",
+ }
+) {
+ // Wraps the `backgroundScript` function to be execute in the target
+ // extension global (currently only in a background service worker,
+ // in follow-ups the same function should also be execute in
+ // other supported extension globals, e.g. an extension page and
+ // a content script).
+ //
+ // The test wrapper does also provide to `backgroundScript` some
+ // helpers to be used as part of the test, these tests are meant to
+ // only cover internals shared by all webidl API bindings through a
+ // mock API namespace only available in tests (and so none of the tests
+ // written with this helpers should be using the browser.test API namespace).
+ function backgroundScriptWrapper(testParams, testFn) {
+ const testLog = msg => {
+ // console messages emitted by workers are not visible in the test logs if not
+ // explicitly collected, and so this testLog helper method does use dump for now
+ // (this way the logs will be visibile as part of the test logs).
+ dump(`"${testParams.extensionId}": ${msg}\n`);
+ };
+
+ const testAsserts = {
+ isErrorInstance(err) {
+ if (!(err instanceof Error)) {
+ throw new Error("Unexpected error: not an instance of Error");
+ }
+ return true;
+ },
+ isInstanceOf(value, globalConstructorName) {
+ if (!(value instanceof self[globalConstructorName])) {
+ throw new Error(
+ `Unexpected error: expected instance of ${globalConstructorName}`
+ );
+ }
+ return true;
+ },
+ equal(val, exp, msg) {
+ if (val !== exp) {
+ throw new Error(
+ `Unexpected error: expected ${exp} but got ${val}. ${msg}`
+ );
+ }
+ },
+ };
+
+ testLog(`Evaluating - test case "${testParams.testDescription}"`);
+ self.onmessage = async evt => {
+ testLog(`Running test case "${testParams.testDescription}"`);
+
+ let testError = null;
+ let testResult;
+ try {
+ testResult = await testFn({ testLog, testAsserts });
+ } catch (err) {
+ testError = { message: err.message, stack: err.stack };
+ testLog(`Unexpected test error: ${err} :: ${err.stack}\n`);
+ }
+
+ evt.ports[0].postMessage({ success: !testError, testError, testResult });
+
+ testLog(`Test case "${testParams.testDescription}" executed`);
+ };
+ testLog(`Wait onmessage event - test case "${testParams.testDescription}"`);
+ }
+
+ async function assertTestResult(result) {
+ if (assertResults) {
+ await assertResults(result);
+ } else {
+ equal(result.testError, undefined, "Expect no errors");
+ ok(result.success, "Test completed successfully");
+ }
+ }
+
+ async function runTestCaseInWorker({ page, extension }) {
+ info(`*** Run test case in an extension service worker`);
+ const result = await page.legacySpawn([], async () => {
+ const { active } = await content.navigator.serviceWorker.ready;
+ const { port1, port2 } = new MessageChannel();
+
+ return new Promise(resolve => {
+ port1.onmessage = evt => resolve(evt.data);
+ active.postMessage("run-test", [port2]);
+ });
+ });
+ info(`*** Assert test case results got from extension service worker`);
+ await assertTestResult({ ...result, extension });
+ }
+
+ // NOTE: prefixing this with `function ` is needed because backgroundScript
+ // is an object property and so it is going to be stringified as
+ // `backgroundScript() { ... }` (which would be detected as a syntax error
+ // on the worker script evaluation phase).
+ const scriptFnParam = ExtensionTestCommon.serializeFunction(backgroundScript);
+ const testOptsParam = `${JSON.stringify({ testDescription, extensionId })}`;
+
+ const testExtData = {
+ useAddonManager: "temporary",
+ manifest: {
+ version: "1",
+ background: {
+ service_worker: "test-sw.js",
+ },
+ browser_specific_settings: {
+ gecko: { id: extensionId },
+ },
+ },
+ files: {
+ "page.html": `<!DOCTYPE html>
+ <head><meta charset="utf-8"></head>
+ <body>
+ <script src="test-sw.js"></script>
+ </body>`,
+ "test-sw.js": `
+ (${backgroundScriptWrapper})(${testOptsParam}, ${scriptFnParam});
+ `,
+ },
+ };
+
+ let cleanupCalled = false;
+ let extension;
+ let page;
+ let swReg;
+
+ async function testCleanup() {
+ if (cleanupCalled) {
+ return;
+ }
+
+ cleanupCalled = true;
+ await unmockHandleAPIRequest(page);
+ await page.close();
+ await extension.unload();
+ await waitForTerminatedWorkers(swReg);
+ }
+
+ info(`Start test case "${testDescription}"`);
+ extension = ExtensionTestUtils.loadExtension(testExtData);
+ await extension.startup();
+
+ swReg = getBackgroundServiceWorkerRegistration(extension);
+ ok(swReg, "Extension background.service_worker should be registered");
+
+ page = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/page.html`,
+ { extension }
+ );
+
+ registerCleanupFunction(testCleanup);
+
+ await mockHandleAPIRequest(page, mockAPIRequestHandler);
+ await runTestCaseInWorker({ page, extension });
+ await testCleanup();
+ info(`End test case "${testDescription}"`);
+}
diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js
new file mode 100644
index 0000000000..489cc3a754
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js
@@ -0,0 +1,486 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_task(async function setup() {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_ext_context_does_have_webidl_bindings() {
+ await runExtensionAPITest("should have a browser global object", {
+ backgroundScript() {
+ const { browser, chrome } = self;
+
+ return {
+ hasExtensionAPI: !!browser,
+ hasExtensionMockAPI: !!browser?.mockExtensionAPI,
+ hasChromeCompatGlobal: !!chrome,
+ hasChromeMockAPI: !!chrome?.mockExtensionAPI,
+ };
+ },
+ assertResults({ testResult, testError }) {
+ Assert.deepEqual(testError, undefined);
+ Assert.deepEqual(
+ testResult,
+ {
+ hasExtensionAPI: true,
+ hasExtensionMockAPI: true,
+ hasChromeCompatGlobal: true,
+ hasChromeMockAPI: true,
+ },
+ "browser and browser.test WebIDL API bindings found"
+ );
+ },
+ });
+});
+
+add_task(async function test_propagated_extension_error() {
+ await runExtensionAPITest(
+ "should throw an extension error on ResultType::EXTENSION_ERROR",
+ {
+ backgroundScript({ testAsserts }) {
+ try {
+ const api = self.browser.mockExtensionAPI;
+ api.methodSyncWithReturn("arg0", 1, { value: "arg2" });
+ } catch (err) {
+ testAsserts.isErrorInstance(err);
+ throw err;
+ }
+ },
+ mockAPIRequestHandler(policy, request) {
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR,
+ value: new Error("Fake Extension Error"),
+ };
+ },
+ assertResults({ testError }) {
+ Assert.deepEqual(testError?.message, "Fake Extension Error");
+ },
+ }
+ );
+});
+
+add_task(async function test_system_errors_donot_leak() {
+ function assertResults({ testError }) {
+ ok(
+ testError?.message?.match(/An unexpected error occurred/),
+ `Got the general unexpected error as expected: ${testError?.message}`
+ );
+ }
+
+ function mockAPIRequestHandler(policy, request) {
+ throw new Error("Fake handleAPIRequest exception");
+ }
+
+ const msg =
+ "should throw an unexpected error occurred if handleAPIRequest throws";
+
+ await runExtensionAPITest(`sync method ${msg}`, {
+ backgroundScript({ testAsserts }) {
+ try {
+ self.browser.mockExtensionAPI.methodSyncWithReturn("arg0");
+ } catch (err) {
+ testAsserts.isErrorInstance(err);
+ throw err;
+ }
+ },
+ mockAPIRequestHandler,
+ assertResults,
+ });
+
+ await runExtensionAPITest(`async method ${msg}`, {
+ backgroundScript({ testAsserts }) {
+ try {
+ self.browser.mockExtensionAPI.methodAsync("arg0");
+ } catch (err) {
+ testAsserts.isErrorInstance(err);
+ throw err;
+ }
+ },
+ mockAPIRequestHandler,
+ assertResults,
+ });
+
+ await runExtensionAPITest(`no return method ${msg}`, {
+ backgroundScript({ testAsserts }) {
+ try {
+ self.browser.mockExtensionAPI.methodNoReturn("arg0");
+ } catch (err) {
+ testAsserts.isErrorInstance(err);
+ throw err;
+ }
+ },
+ mockAPIRequestHandler,
+ assertResults,
+ });
+});
+
+add_task(async function test_call_sync_function_result() {
+ await runExtensionAPITest(
+ "sync API methods should support structured clonable return values",
+ {
+ backgroundScript({ testAsserts }) {
+ const api = self.browser.mockExtensionAPI;
+ const results = {
+ string: api.methodSyncWithReturn("string-result"),
+ nested_prop: api.methodSyncWithReturn({
+ string: "123",
+ number: 123,
+ date: new Date("2020-09-20"),
+ map: new Map([
+ ["a", 1],
+ ["b", 2],
+ ]),
+ }),
+ };
+
+ testAsserts.isInstanceOf(results.nested_prop.date, "Date");
+ testAsserts.isInstanceOf(results.nested_prop.map, "Map");
+ return results;
+ },
+ mockAPIRequestHandler(policy, request) {
+ if (request.apiName === "methodSyncWithReturn") {
+ // Return the first argument unmodified, which will be checked in the
+ // resultAssertFn above.
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE,
+ value: request.args[0],
+ };
+ }
+ throw new Error("Unexpected API method");
+ },
+ assertResults({ testResult, testError }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ Assert.deepEqual(testResult, {
+ string: "string-result",
+ nested_prop: {
+ string: "123",
+ number: 123,
+ date: new Date("2020-09-20"),
+ map: new Map([
+ ["a", 1],
+ ["b", 2],
+ ]),
+ },
+ });
+ },
+ }
+ );
+});
+
+add_task(async function test_call_sync_fn_missing_return() {
+ await runExtensionAPITest(
+ "should throw an unexpected error occurred on missing return value",
+ {
+ backgroundScript() {
+ self.browser.mockExtensionAPI.methodSyncWithReturn("arg0");
+ },
+ mockAPIRequestHandler(policy, request) {
+ return undefined;
+ },
+ assertResults({ testError }) {
+ ok(
+ testError?.message?.match(/An unexpected error occurred/),
+ `Got the general unexpected error as expected: ${testError?.message}`
+ );
+ },
+ }
+ );
+});
+
+add_task(async function test_call_async_throw_extension_error() {
+ await runExtensionAPITest(
+ "an async function can throw an error occurred for param validation errors",
+ {
+ backgroundScript({ testAsserts }) {
+ try {
+ self.browser.mockExtensionAPI.methodAsync("arg0");
+ } catch (err) {
+ testAsserts.isErrorInstance(err);
+ throw err;
+ }
+ },
+ mockAPIRequestHandler(policy, request) {
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR,
+ value: new Error("Fake Param Validation Error"),
+ };
+ },
+ assertResults({ testError }) {
+ Assert.deepEqual(testError?.message, "Fake Param Validation Error");
+ },
+ }
+ );
+});
+
+add_task(async function test_call_async_reject_error() {
+ await runExtensionAPITest(
+ "an async function rejected promise should propagate extension errors",
+ {
+ async backgroundScript({ testAsserts }) {
+ try {
+ await self.browser.mockExtensionAPI.methodAsync("arg0");
+ } catch (err) {
+ testAsserts.isErrorInstance(err);
+ throw err;
+ }
+ },
+ mockAPIRequestHandler(policy, request) {
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE,
+ value: Promise.reject(new Error("Fake API rejected error object")),
+ };
+ },
+ assertResults({ testError }) {
+ Assert.deepEqual(testError?.message, "Fake API rejected error object");
+ },
+ }
+ );
+});
+
+add_task(async function test_call_async_function_result() {
+ await runExtensionAPITest(
+ "async API methods should support structured clonable resolved values",
+ {
+ async backgroundScript({ testAsserts }) {
+ const api = self.browser.mockExtensionAPI;
+ const results = {
+ string: await api.methodAsync("string-result"),
+ nested_prop: await api.methodAsync({
+ string: "123",
+ number: 123,
+ date: new Date("2020-09-20"),
+ map: new Map([
+ ["a", 1],
+ ["b", 2],
+ ]),
+ }),
+ };
+
+ testAsserts.isInstanceOf(results.nested_prop.date, "Date");
+ testAsserts.isInstanceOf(results.nested_prop.map, "Map");
+ return results;
+ },
+ mockAPIRequestHandler(policy, request) {
+ if (request.apiName === "methodAsync") {
+ // Return the first argument unmodified, which will be checked in the
+ // resultAssertFn above.
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE,
+ value: Promise.resolve(request.args[0]),
+ };
+ }
+ throw new Error("Unexpected API method");
+ },
+ assertResults({ testResult, testError }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ Assert.deepEqual(testResult, {
+ string: "string-result",
+ nested_prop: {
+ string: "123",
+ number: 123,
+ date: new Date("2020-09-20"),
+ map: new Map([
+ ["a", 1],
+ ["b", 2],
+ ]),
+ },
+ });
+ },
+ }
+ );
+});
+
+add_task(async function test_call_no_return_throw_extension_error() {
+ await runExtensionAPITest(
+ "no return function call throw an error occurred for param validation errors",
+ {
+ backgroundScript({ testAsserts }) {
+ try {
+ self.browser.mockExtensionAPI.methodNoReturn("arg0");
+ } catch (err) {
+ testAsserts.isErrorInstance(err);
+ throw err;
+ }
+ },
+ mockAPIRequestHandler(policy, request) {
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR,
+ value: new Error("Fake Param Validation Error"),
+ };
+ },
+ assertResults({ testError }) {
+ Assert.deepEqual(testError?.message, "Fake Param Validation Error");
+ },
+ }
+ );
+});
+
+add_task(async function test_call_no_return_without_errors() {
+ await runExtensionAPITest(
+ "handleAPIHandler can return undefined on api calls to methods with no return",
+ {
+ backgroundScript() {
+ self.browser.mockExtensionAPI.methodNoReturn("arg0");
+ },
+ mockAPIRequestHandler(policy, request) {
+ return undefined;
+ },
+ assertResults({ testError }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ },
+ }
+ );
+});
+
+add_task(async function test_async_method_chrome_compatible_callback() {
+ function mockAPIRequestHandler(policy, request) {
+ if (request.args[0] === "fake-async-method-failure") {
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE,
+ value: Promise.reject("this-should-not-be-passed-to-cb-as-parameter"),
+ };
+ }
+
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE,
+ value: Promise.resolve(request.args),
+ };
+ }
+
+ await runExtensionAPITest(
+ "async method should support an optional chrome-compatible callback",
+ {
+ mockAPIRequestHandler,
+ async backgroundScript({ testAsserts }) {
+ const api = self.browser.mockExtensionAPI;
+ const success_cb_params = await new Promise(resolve => {
+ const res = api.methodAsync(
+ { prop: "fake-async-method-success" },
+ (...results) => {
+ resolve(results);
+ }
+ );
+ testAsserts.equal(res, undefined, "no promise should be returned");
+ });
+ const error_cb_params = await new Promise(resolve => {
+ const res = api.methodAsync(
+ "fake-async-method-failure",
+ (...results) => {
+ resolve(results);
+ }
+ );
+ testAsserts.equal(res, undefined, "no promise should be returned");
+ });
+ return { success_cb_params, error_cb_params };
+ },
+ assertResults({ testError, testResult }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ Assert.deepEqual(
+ testResult,
+ {
+ success_cb_params: [[{ prop: "fake-async-method-success" }]],
+ error_cb_params: [],
+ },
+ "Got the expected results from the chrome compatible callbacks"
+ );
+ },
+ }
+ );
+
+ await runExtensionAPITest(
+ "async method with ambiguous args called with a chrome-compatible callback",
+ {
+ mockAPIRequestHandler,
+ async backgroundScript({ testAsserts }) {
+ const api = self.browser.mockExtensionAPI;
+ const success_cb_params = await new Promise(resolve => {
+ const res = api.methodAmbiguousArgsAsync(
+ "arg0",
+ { prop: "arg1" },
+ 3,
+ (...results) => {
+ resolve(results);
+ }
+ );
+ testAsserts.equal(res, undefined, "no promise should be returned");
+ });
+ const error_cb_params = await new Promise(resolve => {
+ const res = api.methodAmbiguousArgsAsync(
+ "fake-async-method-failure",
+ (...results) => {
+ resolve(results);
+ }
+ );
+ testAsserts.equal(res, undefined, "no promise should be returned");
+ });
+ return { success_cb_params, error_cb_params };
+ },
+ assertResults({ testError, testResult }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ Assert.deepEqual(
+ testResult,
+ {
+ success_cb_params: [["arg0", { prop: "arg1" }, 3]],
+ error_cb_params: [],
+ },
+ "Got the expected results from the chrome compatible callbacks"
+ );
+ },
+ }
+ );
+});
+
+add_task(async function test_get_property() {
+ await runExtensionAPITest(
+ "getProperty API request does return a value synchrously",
+ {
+ backgroundScript() {
+ return self.browser.mockExtensionAPI.propertyAsString;
+ },
+ mockAPIRequestHandler(policy, request) {
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE,
+ value: "property-value",
+ };
+ },
+ assertResults({ testError, testResult }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ Assert.deepEqual(
+ testResult,
+ "property-value",
+ "Got the expected result"
+ );
+ },
+ }
+ );
+
+ await runExtensionAPITest(
+ "getProperty API request can return an error object",
+ {
+ backgroundScript({ testAsserts }) {
+ const errObj = self.browser.mockExtensionAPI.propertyAsErrorObject;
+ testAsserts.isErrorInstance(errObj);
+ testAsserts.equal(errObj.message, "fake extension error");
+ },
+ mockAPIRequestHandler(policy, request) {
+ let savedFrame = request.calledSavedFrame;
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE,
+ value: ChromeUtils.createError("fake extension error", savedFrame),
+ };
+ },
+ assertResults({ testError, testResult }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ },
+ }
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js
new file mode 100644
index 0000000000..576ec760d3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js
@@ -0,0 +1,575 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* import-globals-from ../head_service_worker.js */
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_task(async function setup() {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_api_event_manager_methods() {
+ await runExtensionAPITest("extension event manager methods", {
+ backgroundScript({ testAsserts, testLog }) {
+ const api = browser.mockExtensionAPI;
+ const listener = () => {};
+
+ function assertHasListener(expect) {
+ testAsserts.equal(
+ api.onTestEvent.hasListeners(),
+ expect,
+ `onTestEvent.hasListeners should return {expect}`
+ );
+ testAsserts.equal(
+ api.onTestEvent.hasListener(listener),
+ expect,
+ `onTestEvent.hasListeners should return {expect}`
+ );
+ }
+
+ assertHasListener(false);
+ api.onTestEvent.addListener(listener);
+ assertHasListener(true);
+ api.onTestEvent.removeListener(listener);
+ assertHasListener(false);
+ },
+ mockAPIRequestHandler(policy, request) {
+ if (!request.eventListener) {
+ throw new Error(
+ "Unexpected Error: missing ExtensionAPIRequest.eventListener"
+ );
+ }
+ },
+ assertResults({ testError, testResult }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ },
+ });
+});
+
+add_task(async function test_api_event_eventListener_call() {
+ await runExtensionAPITest(
+ "extension event eventListener wrapper does forward calls parameters",
+ {
+ backgroundScript({ testAsserts, testLog }) {
+ const api = browser.mockExtensionAPI;
+ let listener;
+
+ return new Promise((resolve, reject) => {
+ testLog("addListener and wait for event to be fired");
+ listener = (...args) => {
+ testLog("onTestEvent");
+ // Make sure the extension code can access the arguments.
+ try {
+ testAsserts.equal(args[1], "arg1");
+ resolve(args);
+ } catch (err) {
+ reject(err);
+ }
+ };
+ api.onTestEvent.addListener(listener);
+ });
+ },
+ mockAPIRequestHandler(policy, request) {
+ if (!request.eventListener) {
+ throw new Error(
+ "Unexpected Error: missing ExtensionAPIRequest.eventListener"
+ );
+ }
+ if (request.requestType === "addListener") {
+ let args = [{ arg: 0 }, "arg1"];
+ request.eventListener.callListener(args);
+ }
+ },
+ assertResults({ testError, testResult }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ Assert.deepEqual(
+ testResult,
+ [{ arg: 0 }, "arg1"],
+ "Got the expected result"
+ );
+ },
+ }
+ );
+});
+
+add_task(async function test_api_event_eventListener_call_with_result() {
+ await runExtensionAPITest(
+ "extension event eventListener wrapper forwarded call result",
+ {
+ backgroundScript({ testAsserts, testLog }) {
+ const api = browser.mockExtensionAPI;
+ let listener;
+
+ return new Promise((resolve, reject) => {
+ testLog("addListener and wait for event to be fired");
+ listener = (msg, value) => {
+ testLog(`onTestEvent received: ${msg}`);
+ switch (msg) {
+ case "test-result-value":
+ return value;
+ case "test-promise-resolve":
+ return Promise.resolve(value);
+ case "test-promise-reject":
+ return Promise.reject(new Error("test-reject"));
+ case "test-done":
+ resolve(value);
+ break;
+ default:
+ reject(new Error(`Unexpected onTestEvent message: ${msg}`));
+ }
+ };
+ api.onTestEvent.addListener(listener);
+ });
+ },
+ assertResults({ testError, testResult }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ Assert.deepEqual(
+ testResult?.resSync,
+ { prop: "retval" },
+ "Got result from eventListener returning a plain return value"
+ );
+ Assert.deepEqual(
+ testResult?.resAsync,
+ { prop: "promise" },
+ "Got result from eventListener returning a resolved promise"
+ );
+ Assert.deepEqual(
+ testResult?.resAsyncReject,
+ {
+ isInstanceOfError: true,
+ errorMessage: "test-reject",
+ },
+ "got result from eventListener returning a rejected promise"
+ );
+ },
+ mockAPIRequestHandler(policy, request) {
+ if (!request.eventListener) {
+ throw new Error(
+ "Unexpected Error: missing ExtensionAPIRequest.eventListener"
+ );
+ }
+
+ if (request.requestType === "addListener") {
+ Promise.resolve().then(async () => {
+ try {
+ dump(`calling listener, expect a plain return value\n`);
+ const resSync = await request.eventListener.callListener([
+ "test-result-value",
+ { prop: "retval" },
+ ]);
+
+ dump(
+ `calling listener, expect a resolved promise return value\n`
+ );
+ const resAsync = await request.eventListener.callListener([
+ "test-promise-resolve",
+ { prop: "promise" },
+ ]);
+
+ dump(
+ `calling listener, expect a rejected promise return value\n`
+ );
+ const resAsyncReject = await request.eventListener
+ .callListener(["test-promise-reject"])
+ .catch(err => err);
+
+ // call API listeners once more to complete the test
+ let args = {
+ resSync,
+ resAsync,
+ resAsyncReject: {
+ isInstanceOfError: resAsyncReject instanceof Error,
+ errorMessage: resAsyncReject?.message,
+ },
+ };
+ request.eventListener.callListener(["test-done", args]);
+ } catch (err) {
+ dump(`Unexpected error: ${err} :: ${err.stack}\n`);
+ throw err;
+ }
+ });
+ }
+ },
+ }
+ );
+});
+
+add_task(async function test_api_event_eventListener_result_rejected() {
+ await runExtensionAPITest(
+ "extension event eventListener throws (mozIExtensionCallback.call)",
+ {
+ backgroundScript({ testAsserts, testLog }) {
+ const api = browser.mockExtensionAPI;
+ let listener;
+
+ return new Promise((resolve, reject) => {
+ testLog("addListener and wait for event to be fired");
+ listener = (msg, arg1) => {
+ if (msg === "test-done") {
+ testLog(`Resolving result: ${JSON.stringify(arg1)}`);
+ resolve(arg1);
+ return;
+ }
+ throw new Error("FAKE eventListener exception");
+ };
+ api.onTestEvent.addListener(listener);
+ });
+ },
+ assertResults({ testError, testResult }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ Assert.deepEqual(
+ testResult,
+ {
+ isPromise: true,
+ rejectIsError: true,
+ errorMessage: "FAKE eventListener exception",
+ },
+ "Got the expected rejected promise"
+ );
+ },
+ mockAPIRequestHandler(policy, request) {
+ if (!request.eventListener) {
+ throw new Error(
+ "Unexpected Error: missing ExtensionAPIRequest.eventListener"
+ );
+ }
+
+ if (request.requestType === "addListener") {
+ Promise.resolve().then(async () => {
+ const promiseResult = request.eventListener.callListener([]);
+ const isPromise = promiseResult instanceof Promise;
+ const err = await promiseResult.catch(e => e);
+ const rejectIsError = err instanceof Error;
+ request.eventListener.callListener([
+ "test-done",
+ { isPromise, rejectIsError, errorMessage: err?.message },
+ ]);
+ });
+ }
+ },
+ }
+ );
+});
+
+add_task(async function test_api_event_eventListener_throws_on_call() {
+ await runExtensionAPITest(
+ "extension event eventListener throws (mozIExtensionCallback.call)",
+ {
+ backgroundScript({ testAsserts, testLog }) {
+ const api = browser.mockExtensionAPI;
+ let listener;
+
+ return new Promise(resolve => {
+ testLog("addListener and wait for event to be fired");
+ listener = (msg, arg1) => {
+ if (msg === "test-done") {
+ testLog(`Resolving result: ${JSON.stringify(arg1)}`);
+ resolve();
+ return;
+ }
+ throw new Error("FAKE eventListener exception");
+ };
+ api.onTestEvent.addListener(listener);
+ });
+ },
+ assertResults({ testError, testResult }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ },
+ mockAPIRequestHandler(policy, request) {
+ if (!request.eventListener) {
+ throw new Error(
+ "Unexpected Error: missing ExtensionAPIRequest.eventListener"
+ );
+ }
+
+ if (request.requestType === "addListener") {
+ Promise.resolve().then(async () => {
+ request.eventListener.callListener([]);
+ request.eventListener.callListener(["test-done"]);
+ });
+ }
+ },
+ }
+ );
+});
+
+add_task(async function test_send_response_eventListener() {
+ await runExtensionAPITest(
+ "extension event eventListener sendResponse eventListener argument",
+ {
+ backgroundScript({ testAsserts, testLog }) {
+ const api = browser.mockExtensionAPI;
+ let listener;
+
+ return new Promise(resolve => {
+ testLog("addListener and wait for event to be fired");
+ listener = (msg, sendResponse) => {
+ if (msg === "call-sendResponse") {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(() => sendResponse("sendResponse-value"), 20);
+ return true;
+ }
+
+ resolve(msg);
+ };
+ api.onTestEvent.addListener(listener);
+ });
+ },
+ assertResults({ testError, testResult }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ Assert.equal(testResult, "sendResponse-value", "Got expected value");
+ },
+ mockAPIRequestHandler(policy, request) {
+ if (!request.eventListener) {
+ throw new Error(
+ "Unexpected Error: missing ExtensionAPIRequest.eventListener"
+ );
+ }
+
+ if (request.requestType === "addListener") {
+ Promise.resolve().then(async () => {
+ const res = await request.eventListener.callListener(
+ ["call-sendResponse"],
+ {
+ callbackType:
+ Ci.mozIExtensionListenerCallOptions.CALLBACK_SEND_RESPONSE,
+ }
+ );
+ request.eventListener.callListener([res]);
+ });
+ }
+ },
+ }
+ );
+});
+
+add_task(async function test_send_response_multiple_eventListener() {
+ await runExtensionAPITest("multiple extension event eventListeners", {
+ backgroundScript({ testAsserts, testLog }) {
+ const api = browser.mockExtensionAPI;
+ let listenerNoReply;
+ let listenerSendResponseReply;
+
+ return new Promise(resolve => {
+ testLog("addListener and wait for event to be fired");
+ listenerNoReply = (msg, sendResponse) => {
+ return false;
+ };
+ listenerSendResponseReply = (msg, sendResponse) => {
+ if (msg === "call-sendResponse") {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(() => sendResponse("sendResponse-value"), 20);
+ return true;
+ }
+
+ resolve(msg);
+ };
+ api.onTestEvent.addListener(listenerNoReply);
+ api.onTestEvent.addListener(listenerSendResponseReply);
+ });
+ },
+ assertResults({ testError, testResult }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ Assert.equal(testResult, "sendResponse-value", "Got expected value");
+ },
+ mockAPIRequestHandler(policy, request) {
+ if (!request.eventListener) {
+ throw new Error(
+ "Unexpected Error: missing ExtensionAPIRequest.eventListener"
+ );
+ }
+
+ if (request.requestType === "addListener") {
+ this._listeners = this._listeners || [];
+ this._listeners.push(request.eventListener);
+ if (this._listeners.length === 2) {
+ Promise.resolve().then(async () => {
+ const { _listeners } = this;
+ this._listeners = undefined;
+
+ // Reference to the listener to which we should send the
+ // final message to complete the test.
+ const replyListener = _listeners[1];
+
+ const res = await Promise.race(
+ _listeners.map(l =>
+ l.callListener(["call-sendResponse"], {
+ callbackType:
+ Ci.mozIExtensionListenerCallOptions.CALLBACK_SEND_RESPONSE,
+ })
+ )
+ );
+ replyListener.callListener([res]);
+ });
+ }
+ }
+ },
+ });
+});
+
+// Unit test nsIServiceWorkerManager.wakeForExtensionAPIEvent method.
+add_task(async function test_serviceworkermanager_wake_for_api_event_helper() {
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ version: "1.0",
+ background: {
+ service_worker: "sw.js",
+ },
+ browser_specific_settings: {
+ gecko: { id: "test-bg-sw-wakeup@mochi.test" },
+ },
+ },
+ files: {
+ "sw.js": `
+ dump("Background ServiceWorker - executing\\n");
+ const lifecycleEvents = [];
+ self.oninstall = () => {
+ dump('Background ServiceWorker - oninstall\\n');
+ lifecycleEvents.push("install");
+ };
+ self.onactivate = () => {
+ dump('Background ServiceWorker - onactivate\\n');
+ lifecycleEvents.push("activate");
+ };
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "bgsw-getSWEvents") {
+ browser.test.sendMessage("bgsw-gotSWEvents", lifecycleEvents);
+ return;
+ }
+
+ browser.test.fail("Got unexpected test message: " + msg);
+ });
+
+ const fakeListener01 = () => {};
+ const fakeListener02 = () => {};
+
+ // Adding and removing the same listener, and so we expect
+ // ExtensionEventWakeupMap to not have any wakeup listener
+ // for the runtime.onInstalled event.
+ browser.runtime.onInstalled.addListener(fakeListener01);
+ browser.runtime.onInstalled.removeListener(fakeListener01);
+ // Removing the same listener more than ones should make any
+ // difference, and it shouldn't trigger any assertion in
+ // debug builds.
+ browser.runtime.onInstalled.removeListener(fakeListener01);
+
+ browser.runtime.onStartup.addListener(fakeListener02);
+ // Removing an unrelated listener, runtime.onStartup is expected to
+ // still have one wakeup listener tracked by ExtensionEventWakeupMap.
+ browser.runtime.onStartup.removeListener(fakeListener01);
+
+ browser.test.sendMessage("bgsw-executed");
+ dump("Background ServiceWorker - executed\\n");
+ `,
+ },
+ });
+
+ const testWorkerWatcher = new TestWorkerWatcher("../data");
+ let watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension);
+
+ await extension.startup();
+
+ info("Wait for the background service worker to be spawned");
+ ok(
+ await watcher.promiseWorkerSpawned,
+ "The extension service worker has been spawned as expected"
+ );
+
+ await extension.awaitMessage("bgsw-executed");
+
+ extension.sendMessage("bgsw-getSWEvents");
+ let lifecycleEvents = await extension.awaitMessage("bgsw-gotSWEvents");
+ Assert.deepEqual(
+ lifecycleEvents,
+ ["install", "activate"],
+ "Got install and activate lifecycle events as expected"
+ );
+
+ info("Wait for the background service worker to be terminated");
+ ok(
+ await watcher.terminate(),
+ "The extension service worker has been terminated as expected"
+ );
+
+ const swReg = testWorkerWatcher.getRegistration(extension);
+ ok(swReg, "Got a service worker registration");
+ ok(swReg?.activeWorker, "Got an active worker");
+
+ watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension);
+
+ const extensionBaseURL = extension.extension.baseURI.spec;
+
+ async function testWakeupOnAPIEvent(eventName, expectedResult) {
+ const result = await testWorkerWatcher.swm.wakeForExtensionAPIEvent(
+ extensionBaseURL,
+ "runtime",
+ eventName
+ );
+ equal(
+ result,
+ expectedResult,
+ `Got expected result from wakeForExtensionAPIEvent for ${eventName}`
+ );
+ info(
+ `Wait for the background service worker to be spawned for ${eventName}`
+ );
+ ok(
+ await watcher.promiseWorkerSpawned,
+ "The extension service worker has been spawned as expected"
+ );
+ await extension.awaitMessage("bgsw-executed");
+ }
+
+ info("Wake up active worker for API event");
+ // Extension API event listener has been added and removed synchronously by
+ // the worker script, and so we expect the promise to resolve successfully
+ // to `false`.
+ await testWakeupOnAPIEvent("onInstalled", false);
+
+ extension.sendMessage("bgsw-getSWEvents");
+ lifecycleEvents = await extension.awaitMessage("bgsw-gotSWEvents");
+ Assert.deepEqual(
+ lifecycleEvents,
+ [],
+ "No install and activate lifecycle events expected on spawning active worker"
+ );
+
+ info("Wait for the background service worker to be terminated");
+ ok(
+ await watcher.terminate(),
+ "The extension service worker has been terminated as expected"
+ );
+
+ info("Wakeup again with an API event that has been subscribed");
+ // Extension API event listener has been added synchronously (and not removed)
+ // by the worker script, and so we expect the promise to resolve successfully
+ // to `true`.
+ await testWakeupOnAPIEvent("onStartup", true);
+
+ info("Wait for the background service worker to be terminated");
+ ok(
+ await watcher.terminate(),
+ "The extension service worker has been terminated as expected"
+ );
+
+ await extension.unload();
+
+ await Assert.rejects(
+ testWorkerWatcher.swm.wakeForExtensionAPIEvent(
+ extensionBaseURL,
+ "runtime",
+ "onStartup"
+ ),
+ /Not an extension principal or extension disabled/,
+ "Got the expected rejection on wakeForExtensionAPIEvent called for an uninstalled extension"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js
new file mode 100644
index 0000000000..070a45fa95
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js
@@ -0,0 +1,443 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_task(async function setup() {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+// Verify ExtensionAPIRequestHandler handling API requests for
+// an ext-*.js API module running in the local process
+// (toolkit/components/extensions/child/ext-test.js).
+add_task(async function test_sw_api_request_handling_local_process_api() {
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ background: {
+ service_worker: "sw.js",
+ },
+ browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } },
+ },
+ files: {
+ "page.html": "<!DOCTYPE html><body></body>",
+ "sw.js": async function () {
+ browser.test.onMessage.addListener(async msg => {
+ browser.test.succeed("call to test.succeed");
+ browser.test.assertTrue(true, "call to test.assertTrue");
+ browser.test.assertFalse(false, "call to test.assertFalse");
+ // Smoke test assertEq (more complete coverage of the behavior expected
+ // by the test API will be introduced in test_ext_test.html as part of
+ // Bug 1723785).
+ const errorObject = new Error("fake_error_message");
+ browser.test.assertEq(
+ errorObject,
+ errorObject,
+ "call to test.assertEq"
+ );
+
+ // Smoke test for assertThrows/assertRejects.
+ const errorMatchingTestCases = [
+ ["expected error instance", errorObject],
+ ["expected error message string", "fake_error_message"],
+ ["expected regexp", /fake_error/],
+ ["matching function", error => errorObject === error],
+ ["matching Constructor", Error],
+ ];
+
+ browser.test.log("run assertThrows smoke tests");
+
+ const throwFn = () => {
+ throw errorObject;
+ };
+ for (const [msg, expected] of errorMatchingTestCases) {
+ browser.test.assertThrows(
+ throwFn,
+ expected,
+ `call to assertThrow with ${msg}`
+ );
+ }
+
+ browser.test.log("run assertRejects smoke tests");
+
+ const rejectedPromise = Promise.reject(errorObject);
+ for (const [msg, expected] of errorMatchingTestCases) {
+ await browser.test.assertRejects(
+ rejectedPromise,
+ expected,
+ `call to assertRejects with ${msg}`
+ );
+ }
+
+ browser.test.notifyPass("test-completed");
+ });
+ browser.test.sendMessage("bgsw-ready");
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bgsw-ready");
+ extension.sendMessage("test-message-ok");
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+// Verify ExtensionAPIRequestHandler handling API requests for
+// an ext-*.js API module running in the main process
+// (toolkit/components/extensions/parent/ext-alarms.js).
+add_task(async function test_sw_api_request_handling_main_process_api() {
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ background: {
+ service_worker: "sw.js",
+ },
+ permissions: ["alarms"],
+ browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } },
+ },
+ files: {
+ "page.html": "<!DOCTYPE html><body></body>",
+ "sw.js": async function () {
+ browser.alarms.create("test-alarm", { when: Date.now() + 2000000 });
+ const all = await browser.alarms.getAll();
+ if (all.length === 1 && all[0].name === "test-alarm") {
+ browser.test.succeed("Got the expected alarms");
+ } else {
+ browser.test.fail(
+ `browser.alarms.create didn't create the expected alarm: ${JSON.stringify(
+ all
+ )}`
+ );
+ }
+
+ browser.alarms.onAlarm.addListener(alarm => {
+ if (alarm.name === "test-onAlarm") {
+ browser.test.succeed("Got the expected onAlarm event");
+ } else {
+ browser.test.fail(`Got unexpected onAlarm event: ${alarm.name}`);
+ }
+ browser.test.sendMessage("test-completed");
+ });
+
+ browser.alarms.create("test-onAlarm", { when: Date.now() + 1000 });
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("test-completed");
+ await extension.unload();
+});
+
+add_task(async function test_sw_api_request_bgsw_runtime_onMessage() {
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ background: {
+ service_worker: "sw.js",
+ },
+ permissions: [],
+ browser_specific_settings: {
+ gecko: { id: "test-bg-sw-on-message@mochi.test" },
+ },
+ },
+ files: {
+ "page.html": '<!DOCTYPE html><script src="page.js"></script>',
+ "page.js": async function () {
+ browser.test.onMessage.addListener(msg => {
+ if (msg !== "extpage-send-message") {
+ browser.test.fail(`Unexpected message received: ${msg}`);
+ return;
+ }
+ browser.runtime.sendMessage("extpage-send-message");
+ });
+ },
+ "sw.js": async function () {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.sendMessage("bgsw-on-message", msg);
+ });
+ const extURL = browser.runtime.getURL("/");
+ browser.test.sendMessage("ext-url", extURL);
+ },
+ },
+ });
+
+ await extension.startup();
+ const extURL = await extension.awaitMessage("ext-url");
+ equal(
+ extURL,
+ `moz-extension://${extension.uuid}/`,
+ "Got the expected extension url"
+ );
+
+ const extPage = await ExtensionTestUtils.loadContentPage(
+ `${extURL}/page.html`,
+ { extension }
+ );
+ extension.sendMessage("extpage-send-message");
+
+ const msg = await extension.awaitMessage("bgsw-on-message");
+ equal(msg, "extpage-send-message", "Got the expected message");
+ await extPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_sw_api_request_bgsw_runtime_sendMessage() {
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ background: {
+ service_worker: "sw.js",
+ },
+ permissions: [],
+ browser_specific_settings: {
+ gecko: { id: "test-bg-sw-sendMessage@mochi.test" },
+ },
+ },
+ files: {
+ "page.html": '<!DOCTYPE html><script src="page.js"></script>',
+ "page.js": async function () {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.sendMessage("extpage-on-message", msg);
+ });
+
+ browser.test.sendMessage("extpage-ready");
+ },
+ "sw.js": async function () {
+ browser.test.onMessage.addListener(msg => {
+ if (msg !== "bgsw-send-message") {
+ browser.test.fail(`Unexpected message received: ${msg}`);
+ return;
+ }
+ browser.runtime.sendMessage("bgsw-send-message");
+ });
+ const extURL = browser.runtime.getURL("/");
+ browser.test.sendMessage("ext-url", extURL);
+ },
+ },
+ });
+
+ await extension.startup();
+ const extURL = await extension.awaitMessage("ext-url");
+ equal(
+ extURL,
+ `moz-extension://${extension.uuid}/`,
+ "Got the expected extension url"
+ );
+
+ const extPage = await ExtensionTestUtils.loadContentPage(
+ `${extURL}/page.html`,
+ { extension }
+ );
+ await extension.awaitMessage("extpage-ready");
+ extension.sendMessage("bgsw-send-message");
+
+ const msg = await extension.awaitMessage("extpage-on-message");
+ equal(msg, "bgsw-send-message", "Got the expected message");
+ await extPage.close();
+ await extension.unload();
+});
+
+// Verify ExtensionAPIRequestHandler handling API requests that
+// returns a runtinme.Port API object.
+add_task(async function test_sw_api_request_bgsw_connnect_runtime_port() {
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ background: {
+ service_worker: "sw.js",
+ },
+ permissions: [],
+ browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } },
+ },
+ files: {
+ "page.html": '<!DOCTYPE html><script src="page.js"></script>',
+ "page.js": async function () {
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.sendMessage("page-got-port-from-sw");
+ port.postMessage("page-to-sw");
+ });
+ browser.test.sendMessage("page-waiting-port");
+ },
+ "sw.js": async function () {
+ browser.test.onMessage.addListener(msg => {
+ if (msg !== "connect-port") {
+ return;
+ }
+ const port = browser.runtime.connect();
+ if (!port) {
+ browser.test.fail("Got an undefined port");
+ }
+ port.onMessage.addListener((msg, portArgument) => {
+ browser.test.assertTrue(
+ port === portArgument,
+ "Got the expected runtime.Port instance"
+ );
+ browser.test.sendMessage("test-done", msg);
+ });
+ browser.test.sendMessage("sw-waiting-port-message");
+ });
+
+ const portWithError = browser.runtime.connect();
+ portWithError.onDisconnect.addListener(() => {
+ const portError = portWithError.error;
+ browser.test.sendMessage("port-error", {
+ isError: portError instanceof Error,
+ message: portError?.message,
+ });
+ });
+
+ const extURL = browser.runtime.getURL("/");
+ browser.test.sendMessage("ext-url", extURL);
+ browser.test.sendMessage("ext-id", browser.runtime.id);
+ },
+ },
+ });
+
+ await extension.startup();
+ const extURL = await extension.awaitMessage("ext-url");
+ equal(
+ extURL,
+ `moz-extension://${extension.uuid}/`,
+ "Got the expected extension url"
+ );
+
+ const extId = await extension.awaitMessage("ext-id");
+ equal(extId, extension.id, "Got the expected extension id");
+
+ const lastError = await extension.awaitMessage("port-error");
+ Assert.deepEqual(
+ lastError,
+ {
+ isError: true,
+ message: "Could not establish connection. Receiving end does not exist.",
+ },
+ "Got the expected lastError value"
+ );
+
+ const extPage = await ExtensionTestUtils.loadContentPage(
+ `${extURL}/page.html`,
+ { extension }
+ );
+ await extension.awaitMessage("page-waiting-port");
+
+ info("bgsw connect port");
+ extension.sendMessage("connect-port");
+ await extension.awaitMessage("sw-waiting-port-message");
+ info("bgsw waiting port message");
+ await extension.awaitMessage("page-got-port-from-sw");
+ info("page got port from sw, wait to receive event");
+ const msg = await extension.awaitMessage("test-done");
+ equal(msg, "page-to-sw", "Got the expected message");
+ await extPage.close();
+ await extension.unload();
+});
+
+// Verify ExtensionAPIRequestHandler handling API events that should
+// get a runtinme.Port API object as an event argument.
+add_task(async function test_sw_api_request_bgsw_runtime_onConnect() {
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ background: {
+ service_worker: "sw.js",
+ },
+ permissions: [],
+ browser_specific_settings: {
+ gecko: { id: "test-bg-sw-onConnect@mochi.test" },
+ },
+ },
+ files: {
+ "page.html": '<!DOCTYPE html><script src="page.js"></script>',
+ "page.js": async function () {
+ browser.test.onMessage.addListener(msg => {
+ if (msg !== "connect-port") {
+ return;
+ }
+ const port = browser.runtime.connect();
+ port.onMessage.addListener(msg => {
+ browser.test.sendMessage("test-done", msg);
+ });
+ browser.test.sendMessage("page-waiting-port-message");
+ });
+ },
+ "sw.js": async function () {
+ try {
+ const extURL = browser.runtime.getURL("/");
+ browser.test.sendMessage("ext-url", extURL);
+
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.sendMessage("bgsw-got-port-from-page");
+ port.postMessage("sw-to-page");
+ });
+ browser.test.sendMessage("bgsw-waiting-port");
+ } catch (err) {
+ browser.test.fail(`Error on runtime.onConnect: ${err}`);
+ }
+ },
+ },
+ });
+
+ await extension.startup();
+ const extURL = await extension.awaitMessage("ext-url");
+ equal(
+ extURL,
+ `moz-extension://${extension.uuid}/`,
+ "Got the expected extension url"
+ );
+ await extension.awaitMessage("bgsw-waiting-port");
+
+ const extPage = await ExtensionTestUtils.loadContentPage(
+ `${extURL}/page.html`,
+ { extension }
+ );
+ info("ext page connect port");
+ extension.sendMessage("connect-port");
+
+ await extension.awaitMessage("page-waiting-port-message");
+ info("page waiting port message");
+ await extension.awaitMessage("bgsw-got-port-from-page");
+ info("bgsw got port from page, page wait to receive event");
+ const msg = await extension.awaitMessage("test-done");
+ equal(msg, "sw-to-page", "Got the expected message");
+ await extPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_sw_runtime_lastError() {
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ background: {
+ service_worker: "sw.js",
+ },
+ browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } },
+ },
+ files: {
+ "page.html": "<!DOCTYPE html><body></body>",
+ "sw.js": async function () {
+ browser.runtime.sendMessage(() => {
+ const lastError = browser.runtime.lastError;
+ if (!(lastError instanceof Error)) {
+ browser.test.fail(
+ `lastError isn't an Error instance: ${lastError}`
+ );
+ }
+ browser.test.sendMessage("test-lastError-completed");
+ });
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("test-lastError-completed");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js
new file mode 100644
index 0000000000..d8684c1574
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js
@@ -0,0 +1,202 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { ExtensionAPI } = ExtensionCommon;
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+// Because the `mockExtensionAPI` is currently the only "mock" API that has
+// WebIDL bindings, this is the only namespace we can use in our tests. There
+// is no JSON schema for this namespace so we add one here that is tailored for
+// our testing needs.
+const API = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ mockExtensionAPI: {
+ methodAsync: () => {
+ return "some-value";
+ },
+ },
+ };
+ }
+};
+
+const SCHEMA = [
+ {
+ namespace: "mockExtensionAPI",
+ functions: [
+ {
+ name: "methodAsync",
+ type: "function",
+ async: true,
+ parameters: [
+ {
+ name: "arg",
+ type: "string",
+ enum: ["THE_ONLY_VALUE_ALLOWED"],
+ },
+ ],
+ },
+ ],
+ },
+];
+
+add_setup(async function () {
+ await AddonTestUtils.promiseStartupManager();
+
+ // The blob:-URL registered in `registerModules()` below gets loaded at:
+ // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649
+ Services.prefs.setBoolPref(
+ "security.allow_parent_unrestricted_js_loads",
+ true
+ );
+
+ ExtensionParent.apiManager.registerModules({
+ mockExtensionAPI: {
+ schema: `data:,${JSON.stringify(SCHEMA)}`,
+ scopes: ["addon_parent"],
+ paths: [["mockExtensionAPI"]],
+ url: URL.createObjectURL(
+ new Blob([`this.mockExtensionAPI = ${API.toString()}`])
+ ),
+ },
+ });
+});
+
+add_task(async function test_schema_error_is_propagated_to_extension() {
+ await runExtensionAPITest("should throw an extension error", {
+ backgroundScript() {
+ return browser.mockExtensionAPI.methodAsync("UNEXPECTED_VALUE");
+ },
+ mockAPIRequestHandler(policy, request) {
+ return this._handleAPIRequest_orig(policy, request);
+ },
+ assertResults({ testError }) {
+ Assert.ok(
+ /Invalid enumeration value "UNEXPECTED_VALUE"/.test(testError.message)
+ );
+ },
+ });
+});
+
+add_task(async function test_schema_error_no_error_with_expected_value() {
+ await runExtensionAPITest("should not throw any error", {
+ backgroundScript() {
+ return browser.mockExtensionAPI.methodAsync("THE_ONLY_VALUE_ALLOWED");
+ },
+ mockAPIRequestHandler(policy, request) {
+ return this._handleAPIRequest_orig(policy, request);
+ },
+ assertResults({ testError, testResult }) {
+ Assert.deepEqual(testError, undefined);
+ Assert.deepEqual(testResult, "some-value");
+ },
+ });
+});
+
+add_task(async function test_schema_data_not_found_or_unexpected_schema_type() {
+ const { Schemas } = ChromeUtils.importESModule(
+ "resource://gre/modules/Schemas.sys.mjs"
+ );
+
+ const mockSchemaExtContext = {};
+
+ const testSchemasErrorOnWebIDLRequest = testCase => {
+ if (testCase.expectedExceptions) {
+ const expectedExceptions = Array.isArray(testCase.expectedExceptions)
+ ? testCase.expectedExceptions
+ : [testCase.expectedExceptions];
+ expectedExceptions.forEach(expectedException =>
+ Assert.throws(
+ () =>
+ Schemas.checkWebIDLRequestParameters(
+ testCase.mockSchemaExtContext,
+ testCase.mockWebIDLAPIRequest
+ ),
+ expectedException,
+ `Got the expected error on ${testCase.description}`
+ )
+ );
+ } else {
+ throw new Error(
+ `Test case ${testCase.description} is missing mandatory expectedExceptions test case property`
+ );
+ }
+ };
+
+ const TEST_CASES = [
+ {
+ description:
+ "callFunction API request for non existing nested API namespace",
+ mockSchemaExtContext,
+ mockWebIDLAPIRequest: {
+ apiNamespace: "browserSettings.unknownNamespace",
+ apiName: "get",
+ requestType: "callFunction",
+ },
+ expectedExceptions:
+ /API Schema not found for browserSettings\.unknownNamespace/,
+ },
+ {
+ description:
+ "addListener API request for non existing API event property",
+ mockSchemaExtContext,
+ mockWebIDLAPIRequest: {
+ apiNamespace: "browserSettings.nonExistingSetting",
+ apiName: "onChange",
+ requestType: "addListener",
+ },
+ expectedExceptions:
+ /API Schema not found for browserSettings\.nonExistingSetting/,
+ },
+ {
+ description:
+ "callFunction on non existing method from existing nested API namespace",
+ mockSchemaExtContext,
+ mockWebIDLAPIRequest: {
+ apiNamespace: "browserSettings.colorManagement.mode",
+ apiName: "nonExistingMethod",
+ requestType: "callFunction",
+ },
+ expectedExceptions: [
+ /API Schema for "nonExistingMethod" not found in browserSettings\.colorManagement\.mode/,
+ /\(browserSettings\.colorManagement\.mode type is SubModuleProperty\)/,
+ ],
+ },
+ {
+ description:
+ "callFunction on non existing method from existing API namespace",
+ mockSchemaExtContext,
+ mockWebIDLAPIRequest: {
+ apiNamespace: "browserSettings",
+ apiName: "nonExistingMethod",
+ requestType: "callFunction",
+ },
+ expectedExceptions:
+ /API Schema not found for browserSettings\.nonExistingMethod/,
+ },
+ {
+ description:
+ "callFunction on existing property but unexpected schema type",
+ mockSchemaExtContext,
+ mockWebIDLAPIRequest: {
+ apiNamespace: "tabs",
+ apiName: "TAB_ID_NONE",
+ requestType: "callFunction",
+ },
+ expectedExceptions: [
+ /Unexpected API Schema type for tabs.TAB_ID_NONE/,
+ /tabs.TAB_ID_NONE type is ValueProperty/,
+ ],
+ },
+ ];
+
+ TEST_CASES.forEach(testSchemasErrorOnWebIDLRequest);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js
new file mode 100644
index 0000000000..a7310f345e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js
@@ -0,0 +1,99 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { ExtensionAPI } = ExtensionCommon;
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+// Because the `mockExtensionAPI` is currently the only "mock" API that has
+// WebIDL bindings, this is the only namespace we can use in our tests. There
+// is no JSON schema for this namespace so we add one here that is tailored for
+// our testing needs.
+const API = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ mockExtensionAPI: {
+ methodAsync: files => {
+ return files;
+ },
+ },
+ };
+ }
+};
+
+const SCHEMA = [
+ {
+ namespace: "mockExtensionAPI",
+ functions: [
+ {
+ name: "methodAsync",
+ type: "function",
+ async: true,
+ parameters: [
+ {
+ name: "files",
+ type: "array",
+ items: { $ref: "manifest.ExtensionURL" },
+ },
+ ],
+ },
+ ],
+ },
+];
+
+add_setup(async function () {
+ await AddonTestUtils.promiseStartupManager();
+
+ // The blob:-URL registered in `registerModules()` below gets loaded at:
+ // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649
+ Services.prefs.setBoolPref(
+ "security.allow_parent_unrestricted_js_loads",
+ true
+ );
+
+ ExtensionParent.apiManager.registerModules({
+ mockExtensionAPI: {
+ schema: `data:,${JSON.stringify(SCHEMA)}`,
+ scopes: ["addon_parent"],
+ paths: [["mockExtensionAPI"]],
+ url: URL.createObjectURL(
+ new Blob([`this.mockExtensionAPI = ${API.toString()}`])
+ ),
+ },
+ });
+});
+
+add_task(async function test_relative_urls() {
+ await runExtensionAPITest(
+ "should format arguments with the relativeUrl formatter",
+ {
+ backgroundScript() {
+ return browser.mockExtensionAPI.methodAsync([
+ "script-1.js",
+ "script-2.js",
+ ]);
+ },
+ mockAPIRequestHandler(policy, request) {
+ return this._handleAPIRequest_orig(policy, request);
+ },
+ assertResults({ testResult, testError, extension }) {
+ Assert.deepEqual(
+ testResult,
+ [
+ `moz-extension://${extension.uuid}/script-1.js`,
+ `moz-extension://${extension.uuid}/script-2.js`,
+ ],
+ "expected correct url"
+ );
+ Assert.deepEqual(testError, undefined, "expected no error");
+ },
+ }
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js
new file mode 100644
index 0000000000..0d88014f32
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js
@@ -0,0 +1,220 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_task(async function setup() {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_method_return_runtime_port() {
+ await runExtensionAPITest("API method returns an ExtensionPort instance", {
+ backgroundScript({ testAsserts, testLog }) {
+ try {
+ browser.mockExtensionAPI.methodReturnsPort("port-create-error");
+ throw new Error("methodReturnsPort should have raised an exception");
+ } catch (err) {
+ testAsserts.equal(
+ err?.message,
+ "An unexpected error occurred",
+ "Got the expected error"
+ );
+ }
+ const port = browser.mockExtensionAPI.methodReturnsPort(
+ "port-create-success"
+ );
+ testAsserts.equal(!!port, true, "Got a port");
+ testAsserts.equal(
+ typeof port.name,
+ "string",
+ "port.name should be a string"
+ );
+ testAsserts.equal(
+ typeof port.sender,
+ "object",
+ "port.sender should be an object"
+ );
+ testAsserts.equal(
+ typeof port.disconnect,
+ "function",
+ "port.disconnect method"
+ );
+ testAsserts.equal(
+ typeof port.postMessage,
+ "function",
+ "port.postMessage method"
+ );
+ testAsserts.equal(
+ typeof port.onDisconnect?.addListener,
+ "function",
+ "port.onDisconnect.addListener method"
+ );
+ testAsserts.equal(
+ typeof port.onMessage?.addListener,
+ "function",
+ "port.onDisconnect.addListener method"
+ );
+ return new Promise(resolve => {
+ let messages = [];
+ port.onDisconnect.addListener(() => resolve(messages));
+ port.onMessage.addListener((...args) => {
+ messages.push(args);
+ });
+ });
+ },
+ assertResults({ testError, testResult }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ Assert.deepEqual(
+ testResult,
+ [
+ [1, 2],
+ [3, 4],
+ [5, 6],
+ ],
+ "Got the expected results"
+ );
+ },
+ mockAPIRequestHandler(policy, request) {
+ if (request.apiName == "methodReturnsPort") {
+ if (request.args[0] == "port-create-error") {
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE,
+ value: "not-a-valid-port",
+ };
+ }
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE,
+ value: {
+ portId: "port-id-1",
+ name: "a-port-name",
+ },
+ };
+ } else if (request.requestType == "addListener") {
+ if (request.apiObjectType !== "Port") {
+ throw new Error(`Unexpected objectType ${request}`);
+ }
+
+ switch (request.apiName) {
+ case "onDisconnect":
+ this._onDisconnectCb = request.eventListener;
+ return;
+ case "onMessage":
+ Promise.resolve().then(async () => {
+ await request.eventListener.callListener([1, 2]);
+ await request.eventListener.callListener([3, 4]);
+ await request.eventListener.callListener([5, 6]);
+ this._onDisconnectCb.callListener([]);
+ });
+ return;
+ }
+ } else if (
+ request.requestType == "getProperty" &&
+ request.apiObjectType == "Port" &&
+ request.apiName == "sender"
+ ) {
+ return {
+ type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE,
+ value: { id: "fake-sender-id-prop" },
+ };
+ }
+
+ throw new Error(`Unexpected request: ${request}`);
+ },
+ });
+});
+
+add_task(async function test_port_as_event_listener_eventListener_param() {
+ await runExtensionAPITest(
+ "API event eventListener received an ExtensionPort parameter",
+ {
+ backgroundScript({ testAsserts, testLog }) {
+ const api = browser.mockExtensionAPI;
+ let listener;
+
+ return new Promise((resolve, reject) => {
+ testLog("addListener and wait for event to be fired");
+ listener = port => {
+ try {
+ testAsserts.equal(!!port, true, "Got a port parameter");
+ testAsserts.equal(
+ port.name,
+ "a-port-name-2",
+ "Got expected port.name value"
+ );
+ testAsserts.equal(
+ typeof port.disconnect,
+ "function",
+ "port.disconnect method"
+ );
+ testAsserts.equal(
+ typeof port.postMessage,
+ "function",
+ "port.disconnect method"
+ );
+ port.onMessage.addListener((msg, portArg) => {
+ if (msg === "test-done") {
+ testLog("Got a port.onMessage event");
+ testAsserts.equal(
+ portArg?.name,
+ "a-port-name-2",
+ "Got port as last argument"
+ );
+ testAsserts.equal(
+ portArg === port,
+ true,
+ "Got the same port instance as expected"
+ );
+ resolve();
+ } else {
+ reject(
+ new Error(
+ `port.onMessage got an unexpected message: ${msg}`
+ )
+ );
+ }
+ });
+ } catch (err) {
+ reject(err);
+ }
+ };
+ api.onTestEvent.addListener(listener);
+ });
+ },
+ assertResults({ testError }) {
+ Assert.deepEqual(testError, null, "Got no error as expected");
+ },
+ mockAPIRequestHandler(policy, request) {
+ if (
+ request.requestType == "addListener" &&
+ request.apiName == "onTestEvent"
+ ) {
+ request.eventListener.callListener(["arg0", "arg1"], {
+ apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT,
+ apiObjectDescriptor: { portId: "port-id-2", name: "a-port-name-2" },
+ apiObjectPrepended: true,
+ });
+ return;
+ } else if (
+ request.requestType == "addListener" &&
+ request.apiObjectType == "Port" &&
+ request.apiObjectId == "port-id-2"
+ ) {
+ request.eventListener.callListener(["test-done"], {
+ apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT,
+ apiObjectDescriptor: { portId: "port-id-2", name: "a-port-name-2" },
+ });
+ return;
+ }
+
+ throw new Error(`Unexpected request: ${request}`);
+ },
+ }
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.ini b/toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.ini
new file mode 100644
index 0000000000..465f913917
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.ini
@@ -0,0 +1,32 @@
+[DEFAULT]
+head = ../head.js ../head_remote.js ../head_service_worker.js head_webidl_api.js
+firefox-appdir = browser
+tags = webextensions webextensions-webidl-api
+
+prefs =
+ # Enable support for the extension background service worker.
+ extensions.backgroundServiceWorker.enabled=true
+ # Enable Extensions API WebIDL bindings for extension windows.
+ extensions.webidl-api.enabled=true
+ # Enable ExtensionMockAPI WebIDL bindings used for unit tests
+ # related to the API request forwarding and not tied to a particular
+ # extension API.
+ extensions.webidl-api.expose_mock_interface=true
+ # Make sure that loading the default settings for url-classifier-skip-urls
+ # doesn't interfere with running our tests while IDB operations are in
+ # flight by overriding the remote settings server URL to
+ # ensure that the IDB database isn't created in the first place.
+ services.settings.server=data:,#remote-settings-dummy/v1
+
+# NOTE: these tests seems to be timing out because it takes too much time to
+# run all tests and then fully exiting the test.
+skip-if = os == "android" && verify
+
+[test_ext_webidl_api.js]
+[test_ext_webidl_api_event_callback.js]
+skip-if =
+ os == "android" && processor == "x86_64" && debug # Bug 1716308
+[test_ext_webidl_api_request_handler.js]
+[test_ext_webidl_api_schema_errors.js]
+[test_ext_webidl_api_schema_formatters.js]
+[test_ext_webidl_runtime_port.js]
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini
new file mode 100644
index 0000000000..635c89dbbc
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini
@@ -0,0 +1,21 @@
+# Similar to xpcshell-common.ini, except tests here only run
+# when e10s is enabled (with or without out-of-process extensions).
+
+[test_ext_webRequest_eventPage_StreamFilter.js]
+[test_ext_webRequest_filterResponseData.js]
+# tsan failure is for test_filter_301 timing out, bug 1674773
+skip-if =
+ tsan || os == "android" && debug
+ apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs
+ fission # Bug 1762638
+[test_ext_webRequest_redirect_StreamFilter.js]
+[test_ext_webRequest_responseBody.js]
+skip-if = os == "android" && debug
+[test_ext_webRequest_startup_StreamFilter.js]
+skip-if = os == "android" && debug
+[test_ext_webRequest_viewsource_StreamFilter.js]
+skip-if =
+ tsan # Bug 1683730
+ apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs
+ fission # Bug 1762638
+
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
new file mode 100644
index 0000000000..f76456124d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -0,0 +1,436 @@
+[DEFAULT]
+# Some tests of downloads.download() expect a file picker, which is only shown
+# by default when the browser.download.useDownloadDir pref is set to true. This
+# is the case on desktop Firefox, but not on Thunderbird.
+# Force pref value to true to get download tests to pass on Thunderbird.
+prefs = browser.download.useDownloadDir=true
+
+[test_change_remote_mode.js]
+[test_ext_MessageManagerProxy.js]
+skip-if = os == "android" # Bug 1545439
+[test_ext_activityLog.js]
+[test_ext_alarms.js]
+[test_ext_alarms_does_not_fire.js]
+[test_ext_alarms_periodic.js]
+[test_ext_alarms_replaces.js]
+[test_ext_api_permissions.js]
+[test_ext_api_events_listener_calls_exceptions.js]
+[test_ext_asyncAPICall_isHandlingUserInput.js]
+[test_ext_background_api_injection.js]
+skip-if = os == "android" # Bug 1700482
+[test_ext_background_early_shutdown.js]
+[test_ext_background_generated_load_events.js]
+[test_ext_background_generated_reload.js]
+[test_ext_background_global_history.js]
+skip-if = os == "android" # Android does not use Places for history.
+[test_ext_background_private_browsing.js]
+[test_ext_background_runtime_connect_params.js]
+[test_ext_background_sub_windows.js]
+[test_ext_background_teardown.js]
+[test_ext_background_telemetry.js]
+[test_ext_background_type_module.js]
+[test_ext_background_window_properties.js]
+skip-if = os == "android"
+[test_ext_browserSettings.js]
+[test_ext_browserSettings_homepage.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android"
+[test_ext_browsingData.js]
+[test_ext_browsingData_cookies_cache.js]
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[test_ext_browsingData_cookies_cookieStoreId.js]
+[test_ext_browser_style_deprecation.js]
+skip-if = appname == "thunderbird"
+[test_ext_cache_api.js]
+[test_ext_captivePortal.js]
+# As with test_captive_portal_service.js, we use the same limits here.
+skip-if =
+ appname == "thunderbird"
+ os == "android" # CP service is disabled on Android
+ os == "mac" && debug # macosx1014/debug due to 1564534
+run-sequentially = node server exceptions dont replay well
+[test_ext_captivePortal_url.js]
+# As with test_captive_portal_service.js, we use the same limits here.
+skip-if =
+ appname == "thunderbird"
+ os == "android" # CP service is disabled on Android,
+ os == "mac" && debug # macosx1014/debug due to 1564534
+run-sequentially = node server exceptions dont replay well
+[test_ext_cookieBehaviors.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android" # Bug 1683730, Android: Bug 1700482
+ apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs
+ tsan
+ fission # Bug 1762638
+[test_ext_cookies_errors.js]
+[test_ext_cookies_firstParty.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android" # Android: Bug 1680132.
+ tsan
+[test_ext_cookies_onChanged.js]
+[test_ext_cookies_partitionKey.js]
+[test_ext_cookies_samesite.js]
+skip-if = os == "android" # Android: Bug 1680132
+[test_ext_content_security_policy.js]
+skip-if =
+ os == "win" # Bug 1762638
+[test_ext_contentscript_api_injection.js]
+[test_ext_contentscript_async_loading.js]
+skip-if =
+ os == "android" && debug # The generated script takes too long to load on Android debug
+ fission # Bug 1762638
+[test_ext_contentscript_context.js]
+skip-if =
+ tsan # Bug 1683730
+ apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs
+ sessionHistoryInParent # Bug 1762638
+[test_ext_contentscript_context_isolation.js]
+skip-if =
+ tsan # Bug 1683730
+ apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs
+ sessionHistoryInParent # Bug 1762638
+[test_ext_contentscript_create_iframe.js]
+[test_ext_contentscript_csp.js]
+run-sequentially = very high failure rate in parallel
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[test_ext_contentscript_css.js]
+skip-if =
+ os == "linux" && fission # Bug 1762638
+ os == "mac" && debug # Bug 1762638
+[test_ext_contentscript_dynamic_registration.js]
+[test_ext_contentscript_exporthelpers.js]
+[test_ext_contentscript_importmap.js]
+[test_ext_contentscript_in_background.js]
+skip-if = os == "android" # Bug 1700482
+[test_ext_contentscript_json_api.js]
+[test_ext_contentscript_module_import.js]
+[test_ext_contentscript_restrictSchemes.js]
+[test_ext_contentscript_teardown.js]
+skip-if =
+ tsan # Bug 1683730
+[test_ext_contentscript_unregister_during_loadContentScript.js]
+[test_ext_contentscript_xml_prettyprint.js]
+[test_ext_contextual_identities.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android" # Containers are not exposed to android.
+[test_ext_cors_mozextension.js]
+[test_ext_csp_frame_ancestors.js]
+[test_ext_csp_upgrade_requests.js]
+[test_ext_debugging_utils.js]
+[test_ext_dnr_allowAllRequests.js]
+[test_ext_dnr_api.js]
+[test_ext_dnr_download.js]
+skip-if = os == "android" # Android: Bug 1680132; downloads.download goes through the embedder app instead of Gecko.
+[test_ext_dnr_dynamic_rules.js]
+[test_ext_dnr_modifyHeaders.js]
+[test_ext_dnr_private_browsing.js]
+[test_ext_dnr_redirect_transform.js]
+[test_ext_dnr_regexFilter.js]
+[test_ext_dnr_regexFilter_limits.js]
+[test_ext_dnr_session_rules.js]
+[test_ext_dnr_startup_cache.js]
+[test_ext_dnr_static_rules.js]
+[test_ext_dnr_system_restrictions.js]
+[test_ext_dnr_testMatchOutcome.js]
+[test_ext_dnr_tabIds.js]
+[test_ext_dnr_urlFilter.js]
+[test_ext_dnr_webrequest.js]
+[test_ext_dnr_without_webrequest.js]
+[test_ext_dns.js]
+skip-if = os == "android" # Android needs alternative for proxy.settings - bug 1723523
+[test_ext_downloads.js]
+[test_ext_downloads_cookies.js]
+skip-if =
+ os == "android" # downloads API needs to be implemented in GeckoView - bug 1538348
+ win10_2004 # Bug 1718292
+ win11_2009 # Bug 1797751
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[test_ext_downloads_cookieStoreId.js]
+skip-if =
+ os == "android"
+ win10_2004 # Bug 1718292
+[test_ext_downloads_download.js]
+skip-if =
+ tsan # Bug 1683730
+ appname == "thunderbird"
+ os == "android"
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[test_ext_downloads_eventpage.js]
+skip-if = os == "android"
+[test_ext_downloads_misc.js]
+skip-if =
+ os == "android"
+ tsan # Bug 1683730
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[test_ext_downloads_partitionKey.js]
+skip-if = os == "android"
+[test_ext_downloads_private.js]
+skip-if = os == "android"
+[test_ext_downloads_search.js]
+skip-if = os == "android" || tsan # tsan: bug 1612707
+[test_ext_downloads_urlencoded.js]
+skip-if = os == "android"
+[test_ext_error_location.js]
+[test_ext_eventpage_idle.js]
+[test_ext_eventpage_warning.js]
+[test_ext_eventpage_settings.js]
+[test_ext_experiments.js]
+[test_ext_extension.js]
+[test_ext_extension_page_navigated.js]
+[test_ext_extensionPreferencesManager.js]
+[test_ext_extensionSettingsStore.js]
+[test_ext_extension_content_telemetry.js]
+skip-if = os == "android" # checking for telemetry needs to be updated: 1384923
+[test_ext_extension_startup_failure.js]
+[test_ext_extension_startup_telemetry.js]
+[test_ext_file_access.js]
+[test_ext_geckoProfiler_control.js]
+skip-if = os == "android" || tsan # Not shipped on Android. tsan: bug 1612707
+[test_ext_geturl.js]
+[test_ext_idle.js]
+[test_ext_incognito.js]
+skip-if = appname == "thunderbird"
+[test_ext_l10n.js]
+[test_ext_localStorage.js]
+[test_ext_management.js]
+skip-if =
+ os == "win" && !debug # Bug 1419183 disable on Windows
+[test_ext_management_uninstall_self.js]
+[test_ext_messaging_startup.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android" && debug
+[test_ext_networkStatus.js]
+[test_ext_notifications_incognito.js]
+skip-if = appname == "thunderbird"
+[test_ext_notifications_unsupported.js]
+[test_ext_onmessage_removelistener.js]
+skip-if = true # This test no longer tests what it is meant to test.
+[test_ext_permission_xhr.js]
+[test_ext_persistent_events.js]
+[test_ext_privacy.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android" && debug
+ os == "linux" && !debug # Bug 1625455
+[test_ext_privacy_disable.js]
+skip-if = appname == "thunderbird"
+[test_ext_privacy_nonPersistentCookies.js]
+[test_ext_privacy_update.js]
+[test_ext_proxy_authorization_via_proxyinfo.js]
+skip-if = true # Bug 1622433 needs h2 proxy implementation
+[test_ext_proxy_config.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android" # Android: Bug 1680132
+[test_ext_proxy_containerIsolation.js]
+[test_ext_proxy_onauthrequired.js]
+[test_ext_proxy_settings.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android" # proxy settings are not supported on android
+[test_ext_proxy_socks.js]
+skip-if = socketprocess_networking
+run-sequentially = TCPServerSocket fails otherwise
+[test_ext_proxy_speculative.js]
+skip-if =
+ ccov && os == "linux" # bug 1607581
+[test_ext_proxy_startup.js]
+skip-if =
+ ccov && os == "linux" # bug 1607581
+[test_ext_redirects.js]
+skip-if =
+ os == "android" && debug
+[test_ext_runtime_connect_no_receiver.js]
+[test_ext_runtime_getBackgroundPage.js]
+[test_ext_runtime_getBrowserInfo.js]
+[test_ext_runtime_getPlatformInfo.js]
+[test_ext_runtime_id.js]
+skip-if =
+ ccov && os == "linux" # bug 1607581
+[test_ext_runtime_messaging_self.js]
+[test_ext_runtime_onInstalled_and_onStartup.js]
+[test_ext_runtime_ports.js]
+[test_ext_runtime_ports_gc.js]
+[test_ext_runtime_sendMessage.js]
+skip-if =
+ os == "win" && bits == 32 && fission && !debug # Bug 1762638; win7 issue
+[test_ext_runtime_sendMessage_errors.js]
+[test_ext_runtime_sendMessage_multiple.js]
+[test_ext_runtime_sendMessage_no_receiver.js]
+[test_ext_same_site_cookies.js]
+[test_ext_same_site_redirects.js]
+skip-if = os == "android" # Android: Bug 1700482
+[test_ext_sandbox_var.js]
+[test_ext_sandboxed_resource.js]
+[test_ext_schema.js]
+[test_ext_script_filenames.js]
+run-sequentially = very high failure rate in parallel
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[test_ext_scripting_contentScripts.js]
+[test_ext_scripting_contentScripts_css.js]
+skip-if =
+ os == "linux" && debug && fission # Bug 1762638
+ os == "mac" && debug && fission # Bug 1762638
+run-sequentially = very high failure rate in parallel
+[test_ext_scripting_contentScripts_file.js]
+[test_ext_scripting_mv2.js]
+[test_ext_scripting_persistAcrossSessions.js]
+[test_ext_scripting_startupCache.js]
+[test_ext_scripting_updateContentScripts.js]
+[test_ext_shared_workers.js]
+[test_ext_shutdown_cleanup.js]
+[test_ext_simple.js]
+[test_ext_startupData.js]
+[test_ext_startup_cache.js]
+skip-if = os == "android"
+[test_ext_startup_perf.js]
+[test_ext_startup_request_handler.js]
+skip-if = os == "android" # Bug 1700482
+[test_ext_storage_local.js]
+skip-if = os == "android" && debug
+[test_ext_storage_idb_data_migration.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android" && debug
+[test_ext_storage_content_local.js]
+skip-if = os == "android" && debug
+[test_ext_storage_content_sync.js]
+skip-if = os == "android" # Android: Bug 1680132
+[test_ext_storage_content_sync_kinto.js]
+skip-if = os == "android" && debug
+[test_ext_storage_quota_exceeded_errors.js]
+skip-if = os == "android" # Bug 1564871
+[test_ext_storage_managed.js]
+skip-if = os == "android"
+[test_ext_storage_managed_policy.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android"
+[test_ext_storage_sanitizer.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android" # Sanitizer.jsm is not in toolkit.
+[test_ext_storage_session.js]
+[test_ext_storage_sync.js]
+skip-if = os == "android" # Bug 1680132 ; SessionStoreFunctions.sys.mjs relies on SessionStore.sys.mjs that does not exist on Android.
+[test_ext_storage_sync_kinto.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android"
+[test_ext_storage_sync_kinto_crypto.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android"
+[test_ext_storage_tab.js]
+[test_ext_storage_telemetry.js]
+skip-if = os == "android" # checking for telemetry needs to be updated: 1384923
+[test_ext_tab_teardown.js]
+skip-if = os == "android" # Bug 1258975 on android.
+[test_ext_telemetry.js]
+[test_ext_theme_experiments.js]
+[test_ext_trustworthy_origin.js]
+[test_ext_unlimitedStorage.js]
+[test_ext_unload_frame.js]
+skip-if = true # Too frequent intermittent failures
+[test_ext_userScripts.js]
+skip-if = os == "android" # Bug 1700482
+run-sequentially = very high failure rate in parallel
+[test_ext_userScripts_exports.js]
+run-sequentially = very high failure rate in parallel
+[test_ext_userScripts_register.js]
+skip-if =
+ os == "linux" && !fission # Bug 1763197
+ os == "android" # Bug 1763197
+[test_ext_wasm.js]
+[test_ext_webRequest_auth.js]
+skip-if =
+ os == "android" && debug
+[test_ext_webRequest_cached.js]
+skip-if = os == "android" # Bug 1573511
+[test_ext_webRequest_cancelWithReason.js]
+skip-if =
+ os == "android" && processor == 'x86_64' # Bug 1683253
+[test_ext_webRequest_containerIsolation.js]
+[test_ext_webRequest_download.js]
+skip-if = os == "android" # Android: Bug 1680132; downloads.download goes through the embedder app instead of Gecko.
+[test_ext_webRequest_filterTypes.js]
+[test_ext_webRequest_from_extension_page.js]
+[test_ext_webRequest_incognito.js]
+skip-if = os == "android" && debug
+[test_ext_webRequest_filter_urls.js]
+[test_ext_webRequest_host.js]
+skip-if = os == "android" && debug
+[test_ext_webRequest_mergecsp.js]
+skip-if = tsan # Bug 1683730
+[test_ext_webRequest_permission.js]
+skip-if = os == "android" && debug
+[test_ext_webRequest_redirectProperty.js]
+skip-if =
+ os == "android" && processor == 'x86_64' # Bug 1683253
+[test_ext_webRequest_redirect_mozextension.js]
+skip-if = os == "android" # Android: Bug 1680132
+[test_ext_webRequest_requestSize.js]
+[test_ext_webRequest_restrictedHeaders.js]
+[test_ext_webRequest_set_cookie.js]
+skip-if = appname == "thunderbird"
+[test_ext_webRequest_startup.js]
+skip-if = os == "android" # bug 1683159
+[test_ext_webRequest_style_cache.js]
+skip-if = os == "android" # Android: Bug 1680132
+[test_ext_webRequest_suspend.js]
+[test_ext_webRequest_userContextId.js]
+[test_ext_webRequest_viewsource.js]
+[test_ext_webSocket.js]
+run-sequentially = very high failure rate in parallel
+[test_ext_webRequest_webSocket.js]
+skip-if = appname == "thunderbird"
+[test_ext_xhr_capabilities.js]
+[test_ext_xhr_cors.js]
+run-sequentially = very high failure rate in parallel
+[test_native_manifests.js]
+subprocess = true
+skip-if = os == "android"
+[test_ext_permissions.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android" # Bug 1350559
+[test_ext_permissions_api.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android" # Bug 1350559
+[test_ext_permissions_migrate.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android" # Bug 1350559
+[test_ext_permissions_uninstall.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android" # Bug 1350559
+[test_proxy_failover.js]
+[test_proxy_listener.js]
+skip-if = appname == "thunderbird"
+[test_proxy_incognito.js]
+skip-if = os == "android" # incognito not supported on android
+[test_proxy_info_results.js]
+skip-if = os == "win" # bug 1802704
+[test_proxy_userContextId.js]
+[test_QuarantinedDomains.js]
+[test_site_permissions.js]
+[test_webRequest_ancestors.js]
+[test_webRequest_cookies.js]
+[test_webRequest_filtering.js]
+[test_ext_brokenlinks.js]
+skip-if = os == "android" # Android: Bug 1680132
+[test_ext_performance_counters.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android"
+[test_resistfingerprinting_exempt.js]
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini
new file mode 100644
index 0000000000..54074f4608
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini
@@ -0,0 +1,70 @@
+[test_ext_i18n.js]
+skip-if = (os == "win" && debug) || (os == "linux")
+[test_ext_i18n_css.js]
+skip-if =
+ os == "mac" && debug && fission # Bug 1762638
+ (socketprocess_networking || fission) && (os == "linux" && debug) # Bug 1759035
+run-sequentially = very high failure rate in parallel
+[test_ext_contentscript.js]
+skip-if =
+ socketprocess_networking # Bug 1759035
+run-sequentially = very high failure rate in parallel
+[test_ext_contentscript_errors.js]
+skip-if =
+ socketprocess_networking # Bug 1759035
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+run-sequentially = very high failure rate in parallel
+[test_ext_contentscript_about_blank_start.js]
+[test_ext_contentscript_canvas_tainting.js]
+skip-if =
+ os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035
+run-sequentially = very high failure rate in parallel
+
+[test_ext_contentscript_permissions_change.js]
+skip-if =
+ os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035
+ os == "linux" && tsan && fission # bug 1762638
+[test_ext_contentscript_permissions_fetch.js]
+skip-if =
+ os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035
+[test_ext_contentscript_scriptCreated.js]
+skip-if =
+ os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035
+[test_ext_contentscript_triggeringPrincipal.js]
+skip-if =
+ os == "android" # Bug 1680132
+ (os == "win" && debug) # Bug 1438796
+ tsan # Bug 1612707
+ os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035
+ os == "linux" && fission && debug # Bug 1762638
+[test_ext_contentscript_xrays.js]
+skip-if =
+ os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035
+run-sequentially = very high failure rate in parallel
+[test_ext_contentScripts_register.js]
+skip-if =
+ os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035
+ fission # Bug 1762638
+[test_ext_contexts_gc.js]
+skip-if =
+ os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035
+run-sequentially = very high failure rate in parallel
+[test_ext_adoption_with_xrays.js]
+skip-if =
+ os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035
+[test_ext_adoption_with_private_field_xrays.js]
+skip-if = !nightly_build
+ os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035
+run-sequentially = very high failure rate in parallel
+[test_ext_shadowdom.js]
+skip-if = ccov && os == 'linux' # bug 1607581
+ os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035
+[test_ext_web_accessible_resources.js]
+skip-if =
+ apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs
+ os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035
+ sessionHistoryInParent # Bug 1762638
+[test_ext_web_accessible_resources_matches.js]
+skip-if =
+ os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035
+run-sequentially = very high failure rate in parallel
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini
new file mode 100644
index 0000000000..b84e3354c5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini
@@ -0,0 +1,30 @@
+[DEFAULT]
+head = head.js head_e10s.js head_telemetry.js
+tail =
+firefox-appdir = browser
+skip-if = appname == "thunderbird" || os == "android"
+dupe-manifest =
+support-files =
+ data/**
+ xpcshell-content.ini
+tags = webextensions webextensions-e10s
+
+# Make sure that loading the default settings for url-classifier-skip-urls
+# doesn't interfere with running our tests while IDB operations are in
+# flight by overriding the remote settings server URL to
+# ensure that the IDB database isn't created in the first place.
+prefs =
+ services.settings.server=data:,#remote-settings-dummy/v1
+
+[include:xpcshell-common-e10s.ini]
+skip-if =
+ socketprocess_networking # Bug 1759035
+[include:xpcshell-content.ini]
+skip-if =
+ socketprocess_networking && fission # Bug 1759035
+
+# Tests that need to run with e10s only must NOT be placed here,
+# but in xpcshell-common-e10s.ini.
+# A test here will only run on one configuration, e10s + in-process extensions,
+# while the primary target is e10s + out-of-process extensions.
+# xpcshell-common-e10s.ini runs in both configurations.
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini
new file mode 100644
index 0000000000..af26762346
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini
@@ -0,0 +1,21 @@
+[DEFAULT]
+head = head.js head_remote.js head_e10s.js head_legacy_ep.js
+tail =
+firefox-appdir = browser
+skip-if = appname == "thunderbird" || os == "android"
+dupe-manifest =
+
+# Make sure that loading the default settings for url-classifier-skip-urls
+# doesn't interfere with running our tests while IDB operations are in
+# flight by overriding the remote settings server URL to
+# ensure that the IDB database isn't created in the first place.
+prefs =
+ services.settings.server=data:,#remote-settings-dummy/v1
+
+# Bug 1646182: Test the legacy ExtensionPermission backend until we fully
+# migrate to rkv
+[test_ext_permissions.js]
+[test_ext_permissions_api.js]
+[test_ext_permissions_migrate.js]
+[test_ext_permissions_uninstall.js]
+[test_ext_proxy_config.js]
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini
new file mode 100644
index 0000000000..b6055bca46
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini
@@ -0,0 +1,42 @@
+[DEFAULT]
+head = head.js head_remote.js head_e10s.js head_telemetry.js head_sync.js head_storage.js
+tail =
+firefox-appdir = browser
+skip-if =
+ os == "android"
+ os == "win" && socketprocess_networking && fission # Bug 1759035
+ os == "mac" && socketprocess_networking && fission # Bug 1759035
+ # I would put linux here, but debug has too many chunks and only runs this manifest, so I need 1 test to pass
+dupe-manifest =
+support-files =
+ data/**
+ head_dnr.js
+ xpcshell-content.ini
+tags = webextensions remote-webextensions
+
+# Make sure that loading the default settings for url-classifier-skip-urls
+# doesn't interfere with running our tests while IDB operations are in
+# flight by overriding the remote settings server URL to
+# ensure that the IDB database isn't created in the first place.
+prefs =
+ services.settings.server=data:,#remote-settings-dummy/v1
+
+[include:xpcshell-common.ini]
+skip-if =
+ os == "linux" && socketprocess_networking # Bug 1759035
+[include:xpcshell-common-e10s.ini]
+skip-if =
+ os == "linux" && socketprocess_networking # Bug 1759035
+[include:xpcshell-content.ini]
+skip-if =
+ os == "linux" && socketprocess_networking # Bug 1759035
+[test_ext_contentscript_perf_observers.js] # Inexplicably, PerformanceObserver used in the test doesn't fire in non-e10s mode.
+skip-if = tsan
+ os == "linux" && socketprocess_networking # Bug 1759035
+[test_ext_contentscript_xorigin_frame.js]
+skip-if =
+ os == "linux" && socketprocess_networking # Bug 1759035
+[test_WebExtensionContentScript.js]
+[test_ext_ipcBlob.js]
+skip-if = os == 'android' && processor == 'x86_64'
+ os == "linux" && socketprocess_networking # Bug 1759035
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-serviceworker.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-serviceworker.ini
new file mode 100644
index 0000000000..d8f029ab2d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-serviceworker.ini
@@ -0,0 +1,37 @@
+[DEFAULT]
+head = head.js head_remote.js head_e10s.js head_telemetry.js head_sync.js head_storage.js head_service_worker.js
+tail =
+firefox-appdir = browser
+skip-if = os == "android"
+dupe-manifest = true
+support-files =
+ data/**
+tags = webextensions sw-webextensions
+run-sequentially = Bug 1760041 pass logged after tests when running multiple ini files
+
+prefs =
+ extensions.backgroundServiceWorker.enabled=true
+ extensions.backgroundServiceWorker.forceInTestExtension=true
+ extensions.webextensions.remote=true
+
+[test_ext_alarms.js]
+[test_ext_alarms_does_not_fire.js]
+[test_ext_alarms_periodic.js]
+[test_ext_alarms_replaces.js]
+[test_ext_background_service_worker.js]
+[test_ext_browserSettings.js]
+[test_ext_browserSettings_homepage.js]
+skip-if =
+ appname == "thunderbird"
+ os == "android"
+[test_ext_contentscript_dynamic_registration.js]
+[test_ext_dns.js]
+[test_ext_runtime_getBackgroundPage.js]
+[test_ext_scripting_contentScripts.js]
+[test_ext_scripting_contentScripts_css.js]
+skip-if =
+ os == "linux" && debug && fission # Bug 1762638
+ os == "mac" && debug && fission # Bug 1762638
+run-sequentially = very high failure rate in parallel
+[test_ext_scripting_contentScripts_file.js]
+[test_ext_scripting_updateContentScripts.js]
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell.ini b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..f42349d7fc
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -0,0 +1,101 @@
+[DEFAULT]
+head = head.js head_telemetry.js head_sync.js head_storage.js
+firefox-appdir = browser
+dupe-manifest =
+support-files =
+ data/**
+ head_dnr.js
+ xpcshell-content.ini
+tags = webextensions in-process-webextensions condprof
+
+# Make sure that loading the default settings for url-classifier-skip-urls
+# doesn't interfere with running our tests while IDB operations are in
+# flight by overriding the remote settings server URL to
+# ensure that the IDB database isn't created in the first place.
+prefs =
+ services.settings.server=data:,#remote-settings-dummy/v1
+
+# This file contains tests which are not affected by multi-process
+# configuration, or do not support out-of-process content or extensions
+# for one reason or another.
+#
+# Tests which are affected by remote content or remote extensions should
+# go in one of:
+#
+# - xpcshell-common.ini
+# For tests which should run in all configurations.
+# - xpcshell-common-e10s.ini
+# For tests which should run in all configurations where e10s is enabled.
+# - xpcshell-remote.ini
+# For tests which should only run with both remote extensions and remote content.
+# - xpcshell-content.ini
+# For tests which rely on content pages, and should run in all configurations.
+# - xpcshell-e10s.ini
+# For tests which rely on content pages, and should only run with remote content
+# but in-process extensions.
+
+[test_ExtensionShortcutKeyMap.js]
+[test_ExtensionStorageSync_migration_kinto.js]
+skip-if = os == 'android' # Not shipped on Android
+ condprof # Bug 1769184 - by design for now
+[test_MatchPattern.js]
+[test_StorageSyncService.js]
+skip-if = os == 'android' && processor == 'x86_64'
+[test_WebExtensionPolicy.js]
+
+[test_csp_custom_policies.js]
+[test_csp_validator.js]
+[test_ext_clear_cached_resources.js]
+[test_ext_contexts.js]
+[test_ext_json_parser.js]
+[test_ext_geckoProfiler_schema.js]
+skip-if = os == 'android' # Not shipped on Android
+[test_ext_manifest.js]
+[test_ext_manifest_content_security_policy.js]
+[test_ext_manifest_incognito.js]
+[test_ext_indexedDB_principal.js]
+[test_ext_manifest_minimum_chrome_version.js]
+[test_ext_manifest_minimum_opera_version.js]
+[test_ext_manifest_themes.js]
+[test_ext_permission_warnings.js]
+[test_ext_schemas.js]
+head = head.js head_schemas.js
+[test_ext_schemas_roots.js]
+[test_ext_schemas_async.js]
+[test_ext_schemas_allowed_contexts.js]
+[test_ext_schemas_interactive.js]
+[test_ext_schemas_manifest_permissions.js]
+skip-if =
+ condprof # Bug 1769184 - by design for now
+[test_ext_schemas_privileged.js]
+skip-if =
+ condprof # Bug 1769184 - by design for now
+[test_ext_schemas_revoke.js]
+[test_ext_schemas_versioned.js]
+head = head.js head_schemas.js
+[test_ext_secfetch.js]
+skip-if =
+ socketprocess_networking # Bug 1759035
+run-sequentially = very high failure rate in parallel
+[test_ext_shared_array_buffer.js]
+[test_ext_startup_cache_telemetry.js]
+[test_ext_test_mock.js]
+[test_ext_test_wrapper.js]
+[test_ext_unknown_permissions.js]
+[test_ext_webRequest_urlclassification.js]
+[test_extension_permissions_migration.js]
+skip-if =
+ condprof # Bug 1769184 - by design for now
+[test_extension_permissions_migrate_kvstore_path.js]
+skip-if =
+ condprof # Bug 1769184 - by design for now
+[test_load_all_api_modules.js]
+[test_locale_converter.js]
+[test_locale_data.js]
+
+[test_ext_runtime_sendMessage_args.js]
+
+[include:xpcshell-common.ini]
+run-if = os == 'android' # Android has no remote extensions, Bug 1535365
+[include:xpcshell-content.ini]
+run-if = os == 'android' # Android has no remote extensions, Bug 1535365